|
|
|
# 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.
|
|
|
|
|
|
|
|
"""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
|
during executable lookup, do not search PATH if a directory component is given
This will always be wrong, because when a directory component is
provided we need to match an exact filename on a manual search path, for
example find_program with dirs: or the current meson.build subdir.
If we ever get this far, shutil.which will do the same "is there a
dirname, if so just check whether the filename exists relative to
cwd"... except that the documented meson lookup path is that we check
relative to meson.build subdir, not relative to the cwd, and the cwd
could be anything, but is probably the root sourcedir.
Since internally, meson does not actually os.chdir into the sourcedir,
it could be absolutely anything at all, though.
...
The actual returned name for shutil.which(name) given a literal pathname
with a directory component is "return name" without adding the absolute
path, which means that this is double-broken. Not only does it find
things we didn't expect, the resulting ExternalProgram object doesn't
have the correct path to the program, so it will report "found" and then
fail to actually run when the current directory is changed, for example
by ninja -C.
Fixes #9262
3 years ago
|
|
|
# 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')
|