diff options
Diffstat (limited to 'mesonbuild/programs.py')
-rw-r--r-- | mesonbuild/programs.py | 375 |
1 files changed, 375 insertions, 0 deletions
diff --git a/mesonbuild/programs.py b/mesonbuild/programs.py new file mode 100644 index 0000000..64f7c29 --- /dev/null +++ b/mesonbuild/programs.py @@ -0,0 +1,375 @@ +# Copyright 2013-2020 The Meson development team + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +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 + +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 + 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: + from . import build + raw_cmd = self.get_command() + ['--version'] + if interpreter: + res = interpreter.run_command_impl(interpreter.current_node, (self, ['--version']), + {'capture': True, + 'check': True, + 'env': build.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. (It's 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, chcking the cross file plus any default options.""" + # Lookup in cross or machine file. + potential_cmd = env.lookup_binary_entry(for_machine, 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(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') |