blob: b73f9e4025df5d579c3a05b32ed025462078736a [file] [log] [blame]
# 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
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')