| # 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, 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 |
| 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() + ['--version'] |
| if interpreter: |
| res = interpreter.run_command_impl((self, ['--version']), |
| {'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. (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, 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') |