# SPDX-License-Identifier: Apache-2.0 # Copyright 2013-2020 The Meson development team from __future__ import annotations """Representations and logic for External and Internal Programs.""" import functools import os import shutil import stat import sys import re import typing as T from pathlib import Path from . import mesonlib from . import mlog from .mesonlib import MachineChoice, OrderedSet if T.TYPE_CHECKING: from .environment import Environment from .interpreter import Interpreter class ExternalProgram(mesonlib.HoldableObject): """A program that is found on the system.""" windows_exts = ('exe', 'msc', 'com', 'bat', 'cmd') for_machine = MachineChoice.BUILD def __init__(self, name: str, command: T.Optional[T.List[str]] = None, silent: bool = False, search_dir: T.Optional[str] = None, extra_search_dirs: T.Optional[T.List[str]] = None): self.name = name self.path: T.Optional[str] = None self.cached_version: T.Optional[str] = None self.version_arg = '--version' if command is not None: self.command = mesonlib.listify(command) if mesonlib.is_windows(): cmd = self.command[0] args = self.command[1:] # Check whether the specified cmd is a path to a script, in # which case we need to insert the interpreter. If not, try to # use it as-is. ret = self._shebang_to_cmd(cmd) if ret: self.command = ret + args else: self.command = [cmd] + args else: all_search_dirs = [search_dir] if extra_search_dirs: all_search_dirs += extra_search_dirs for d in all_search_dirs: self.command = self._search(name, d) if self.found(): break if self.found(): # Set path to be the last item that is actually a file (in order to # skip options in something like ['python', '-u', 'file.py']. If we # can't find any components, default to the last component of the path. for arg in reversed(self.command): if arg is not None and os.path.isfile(arg): self.path = arg break else: self.path = self.command[-1] if not silent: # ignore the warning because derived classes never call this __init__ # method, and thus only the found() method of this class is ever executed if self.found(): # lgtm [py/init-calls-subclass] mlog.log('Program', mlog.bold(name), 'found:', mlog.green('YES'), '(%s)' % ' '.join(self.command)) else: mlog.log('Program', mlog.bold(name), 'found:', mlog.red('NO')) def summary_value(self) -> T.Union[str, mlog.AnsiDecorator]: if not self.found(): return mlog.red('NO') return self.path def __repr__(self) -> str: r = '<{} {!r} -> {!r}>' return r.format(self.__class__.__name__, self.name, self.command) def description(self) -> str: '''Human friendly description of the command''' return ' '.join(self.command) def get_version(self, interpreter: T.Optional['Interpreter'] = None) -> str: if not self.cached_version: raw_cmd = self.get_command() + [self.version_arg] if interpreter: res = interpreter.run_command_impl((self, [self.version_arg]), {'capture': True, 'check': True, 'env': mesonlib.EnvironmentVariables()}, True) o, e = res.stdout, res.stderr else: p, o, e = mesonlib.Popen_safe(raw_cmd) if p.returncode != 0: cmd_str = mesonlib.join_args(raw_cmd) raise mesonlib.MesonException(f'Command {cmd_str!r} failed with status {p.returncode}.') output = o.strip() if not output: output = e.strip() match = re.search(r'([0-9][0-9\.]+)', output) if not match: raise mesonlib.MesonException(f'Could not find a version number in output of {raw_cmd!r}') self.cached_version = match.group(1) return self.cached_version @classmethod def from_bin_list(cls, env: 'Environment', for_machine: MachineChoice, name: str) -> 'ExternalProgram': # There is a static `for_machine` for this class because the binary # always runs on the build platform. (Its host platform is our build # platform.) But some external programs have a target platform, so this # is what we are specifying here. command = env.lookup_binary_entry(for_machine, name) if command is None: return NonExistingExternalProgram() return cls.from_entry(name, command) @staticmethod @functools.lru_cache(maxsize=None) def _windows_sanitize_path(path: str) -> str: # Ensure that we use USERPROFILE even when inside MSYS, MSYS2, Cygwin, etc. if 'USERPROFILE' not in os.environ: return path # The WindowsApps directory is a bit of a problem. It contains # some zero-sized .exe files which have "reparse points", that # might either launch an installed application, or might open # a page in the Windows Store to download the application. # # To handle the case where the python interpreter we're # running on came from the Windows Store, if we see the # WindowsApps path in the search path, replace it with # dirname(sys.executable). appstore_dir = Path(os.environ['USERPROFILE']) / 'AppData' / 'Local' / 'Microsoft' / 'WindowsApps' paths = [] for each in path.split(os.pathsep): if Path(each) != appstore_dir: paths.append(each) elif 'WindowsApps' in sys.executable: paths.append(os.path.dirname(sys.executable)) return os.pathsep.join(paths) @staticmethod def from_entry(name: str, command: T.Union[str, T.List[str]]) -> 'ExternalProgram': if isinstance(command, list): if len(command) == 1: command = command[0] # We cannot do any searching if the command is a list, and we don't # need to search if the path is an absolute path. if isinstance(command, list) or os.path.isabs(command): if isinstance(command, str): command = [command] return ExternalProgram(name, command=command, silent=True) assert isinstance(command, str) # Search for the command using the specified string! return ExternalProgram(command, silent=True) @staticmethod def _shebang_to_cmd(script: str) -> T.Optional[T.List[str]]: """ Check if the file has a shebang and manually parse it to figure out the interpreter to use. This is useful if the script is not executable or if we're on Windows (which does not understand shebangs). """ try: with open(script, encoding='utf-8') as f: first_line = f.readline().strip() if first_line.startswith('#!'): # In a shebang, everything before the first space is assumed to # be the command to run and everything after the first space is # the single argument to pass to that command. So we must split # exactly once. commands = first_line[2:].split('#')[0].strip().split(maxsplit=1) if mesonlib.is_windows(): # Windows does not have UNIX paths so remove them, # but don't remove Windows paths if commands[0].startswith('/'): commands[0] = commands[0].split('/')[-1] if len(commands) > 0 and commands[0] == 'env': commands = commands[1:] # Windows does not ship python3.exe, but we know the path to it if len(commands) > 0 and commands[0] == 'python3': commands = mesonlib.python_command + commands[1:] elif mesonlib.is_haiku(): # Haiku does not have /usr, but a lot of scripts assume that # /usr/bin/env always exists. Detect that case and run the # script with the interpreter after it. if commands[0] == '/usr/bin/env': commands = commands[1:] # We know what python3 is, we're running on it if len(commands) > 0 and commands[0] == 'python3': commands = mesonlib.python_command + commands[1:] else: # Replace python3 with the actual python3 that we are using if commands[0] == '/usr/bin/env' and commands[1] == 'python3': commands = mesonlib.python_command + commands[2:] elif commands[0].split('/')[-1] == 'python3': commands = mesonlib.python_command + commands[1:] return commands + [script] except Exception as e: mlog.debug(str(e)) mlog.debug(f'Unusable script {script!r}') return None def _is_executable(self, path: str) -> bool: suffix = os.path.splitext(path)[-1].lower()[1:] execmask = stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH if mesonlib.is_windows(): if suffix in self.windows_exts: return True elif os.stat(path).st_mode & execmask: return not os.path.isdir(path) return False def _search_dir(self, name: str, search_dir: T.Optional[str]) -> T.Optional[list]: if search_dir is None: return None trial = os.path.join(search_dir, name) if os.path.exists(trial): if self._is_executable(trial): return [trial] # Now getting desperate. Maybe it is a script file that is # a) not chmodded executable, or # b) we are on windows so they can't be directly executed. return self._shebang_to_cmd(trial) else: if mesonlib.is_windows(): for ext in self.windows_exts: trial_ext = f'{trial}.{ext}' if os.path.exists(trial_ext): return [trial_ext] return None def _search_windows_special_cases(self, name: str, command: str) -> T.List[T.Optional[str]]: ''' Lots of weird Windows quirks: 1. PATH search for @name returns files with extensions from PATHEXT, but only self.windows_exts are executable without an interpreter. 2. @name might be an absolute path to an executable, but without the extension. This works inside MinGW so people use it a lot. 3. The script is specified without an extension, in which case we have to manually search in PATH. 4. More special-casing for the shebang inside the script. ''' if command: # On Windows, even if the PATH search returned a full path, we can't be # sure that it can be run directly if it's not a native executable. # For instance, interpreted scripts sometimes need to be run explicitly # with an interpreter if the file association is not done properly. name_ext = os.path.splitext(command)[1] if name_ext[1:].lower() in self.windows_exts: # Good, it can be directly executed return [command] # Try to extract the interpreter from the shebang commands = self._shebang_to_cmd(command) if commands: return commands return [None] # Maybe the name is an absolute path to a native Windows # executable, but without the extension. This is technically wrong, # but many people do it because it works in the MinGW shell. if os.path.isabs(name): for ext in self.windows_exts: command = f'{name}.{ext}' if os.path.exists(command): return [command] # On Windows, interpreted scripts must have an extension otherwise they # cannot be found by a standard PATH search. So we do a custom search # where we manually search for a script with a shebang in PATH. search_dirs = self._windows_sanitize_path(os.environ.get('PATH', '')).split(';') for search_dir in search_dirs: commands = self._search_dir(name, search_dir) if commands: return commands return [None] def _search(self, name: str, search_dir: T.Optional[str]) -> T.List[T.Optional[str]]: ''' Search in the specified dir for the specified executable by name and if not found search in PATH ''' commands = self._search_dir(name, search_dir) if commands: return commands # If there is a directory component, do not look in PATH if os.path.dirname(name) and not os.path.isabs(name): return [None] # Do a standard search in PATH path = os.environ.get('PATH', None) if mesonlib.is_windows() and path: path = self._windows_sanitize_path(path) command = shutil.which(name, path=path) if mesonlib.is_windows(): return self._search_windows_special_cases(name, command) # On UNIX-like platforms, shutil.which() is enough to find # all executables whether in PATH or with an absolute path return [command] def found(self) -> bool: return self.command[0] is not None def get_command(self) -> T.List[str]: return self.command[:] def get_path(self) -> T.Optional[str]: return self.path def get_name(self) -> str: return self.name class NonExistingExternalProgram(ExternalProgram): # lgtm [py/missing-call-to-init] "A program that will never exist" def __init__(self, name: str = 'nonexistingprogram') -> None: self.name = name self.command = [None] self.path = None def __repr__(self) -> str: r = '<{} {!r} -> {!r}>' return r.format(self.__class__.__name__, self.name, self.command) def found(self) -> bool: return False class OverrideProgram(ExternalProgram): """A script overriding a program.""" def find_external_program(env: 'Environment', for_machine: MachineChoice, name: str, display_name: str, default_names: T.List[str], allow_default_for_cross: bool = True) -> T.Generator['ExternalProgram', None, None]: """Find an external program, checking the cross file plus any default options.""" potential_names = OrderedSet(default_names) potential_names.add(name) # Lookup in cross or machine file. for potential_name in potential_names: potential_cmd = env.lookup_binary_entry(for_machine, potential_name) if potential_cmd is not None: mlog.debug(f'{display_name} binary for {for_machine} specified from cross file, native file, ' f'or env var as {potential_cmd}') yield ExternalProgram.from_entry(potential_name, potential_cmd) # We never fallback if the user-specified option is no good, so # stop returning options. return mlog.debug(f'{display_name} binary missing from cross or native file, or env var undefined.') # Fallback on hard-coded defaults, if a default binary is allowed for use # with cross targets, or if this is not a cross target if allow_default_for_cross or not (for_machine is MachineChoice.HOST and env.is_cross_build(for_machine)): for potential_path in default_names: mlog.debug(f'Trying a default {display_name} fallback at', potential_path) yield ExternalProgram(potential_path, silent=True) else: mlog.debug('Default target is not allowed for cross use')