diff options
Diffstat (limited to 'meson/mesonbuild/modules/python.py')
-rw-r--r-- | meson/mesonbuild/modules/python.py | 661 |
1 files changed, 661 insertions, 0 deletions
diff --git a/meson/mesonbuild/modules/python.py b/meson/mesonbuild/modules/python.py new file mode 100644 index 000000000..c4ebbc77b --- /dev/null +++ b/meson/mesonbuild/modules/python.py @@ -0,0 +1,661 @@ +# Copyright 2018 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. + +import os +import json +import shutil +import typing as T + +from pathlib import Path +from .. import mesonlib +from ..mesonlib import MachineChoice, MesonException +from . import ExtensionModule +from ..interpreterbase import ( + noPosargs, noKwargs, permittedKwargs, + InvalidArguments, + FeatureNew, FeatureNewKwargs, disablerIfNotFound +) +from ..interpreter import ExternalProgramHolder, extract_required_kwarg, permitted_dependency_kwargs +from ..build import known_shmod_kwargs +from .. import mlog +from ..environment import detect_cpu_family +from ..dependencies import DependencyMethods, PkgConfigDependency, NotFoundDependency, SystemDependency +from ..programs import ExternalProgram, NonExistingExternalProgram + +mod_kwargs = {'subdir'} +mod_kwargs.update(known_shmod_kwargs) +mod_kwargs -= {'name_prefix', 'name_suffix'} + +class PythonDependency(SystemDependency): + + def __init__(self, python_holder, environment, kwargs): + super().__init__('python', environment, kwargs) + self.name = 'python' + self.static = kwargs.get('static', False) + self.embed = kwargs.get('embed', False) + self.version = python_holder.version + self.platform = python_holder.platform + self.pkgdep = None + self.variables = python_holder.variables + self.paths = python_holder.paths + self.link_libpython = python_holder.link_libpython + self.info: T.Optional[T.Dict[str, str]] = None + if mesonlib.version_compare(self.version, '>= 3.0'): + self.major_version = 3 + else: + self.major_version = 2 + + # We first try to find the necessary python variables using pkgconfig + if DependencyMethods.PKGCONFIG in self.methods and not python_holder.is_pypy: + pkg_version = self.variables.get('LDVERSION') or self.version + pkg_libdir = self.variables.get('LIBPC') + pkg_embed = '-embed' if self.embed and mesonlib.version_compare(self.version, '>=3.8') else '' + pkg_name = f'python-{pkg_version}{pkg_embed}' + + # If python-X.Y.pc exists in LIBPC, we will try to use it + if pkg_libdir is not None and Path(os.path.join(pkg_libdir, f'{pkg_name}.pc')).is_file(): + old_pkg_libdir = os.environ.get('PKG_CONFIG_LIBDIR') + old_pkg_path = os.environ.get('PKG_CONFIG_PATH') + + os.environ.pop('PKG_CONFIG_PATH', None) + + if pkg_libdir: + os.environ['PKG_CONFIG_LIBDIR'] = pkg_libdir + + try: + self.pkgdep = PkgConfigDependency(pkg_name, environment, kwargs) + mlog.debug(f'Found "{pkg_name}" via pkgconfig lookup in LIBPC ({pkg_libdir})') + py_lookup_method = 'pkgconfig' + except MesonException as e: + mlog.debug(f'"{pkg_name}" could not be found in LIBPC ({pkg_libdir})') + mlog.debug(e) + + if old_pkg_path is not None: + os.environ['PKG_CONFIG_PATH'] = old_pkg_path + + if old_pkg_libdir is not None: + os.environ['PKG_CONFIG_LIBDIR'] = old_pkg_libdir + else: + os.environ.pop('PKG_CONFIG_LIBDIR', None) + else: + mlog.debug(f'"{pkg_name}" could not be found in LIBPC ({pkg_libdir}), this is likely due to a relocated python installation') + + # If lookup via LIBPC failed, try to use fallback PKG_CONFIG_LIBDIR/PKG_CONFIG_PATH mechanisms + if self.pkgdep is None or not self.pkgdep.found(): + try: + self.pkgdep = PkgConfigDependency(pkg_name, environment, kwargs) + mlog.debug(f'Found "{pkg_name}" via fallback pkgconfig lookup in PKG_CONFIG_LIBDIR/PKG_CONFIG_PATH') + py_lookup_method = 'pkgconfig-fallback' + except MesonException as e: + mlog.debug(f'"{pkg_name}" could not be found via fallback pkgconfig lookup in PKG_CONFIG_LIBDIR/PKG_CONFIG_PATH') + mlog.debug(e) + + if self.pkgdep and self.pkgdep.found(): + self.compile_args = self.pkgdep.get_compile_args() + self.link_args = self.pkgdep.get_link_args() + self.is_found = True + self.pcdep = self.pkgdep + else: + self.pkgdep = None + + # Finally, try to find python via SYSCONFIG as a final measure + if DependencyMethods.SYSCONFIG in self.methods: + if mesonlib.is_windows(): + self._find_libpy_windows(environment) + else: + self._find_libpy(python_holder, environment) + if self.is_found: + mlog.debug(f'Found "python-{self.version}" via SYSCONFIG module') + py_lookup_method = 'sysconfig' + + if self.is_found: + mlog.log('Dependency', mlog.bold(self.name), 'found:', mlog.green(f'YES ({py_lookup_method})')) + else: + mlog.log('Dependency', mlog.bold(self.name), 'found:', mlog.red('NO')) + + def _find_libpy(self, python_holder, environment): + if python_holder.is_pypy: + if self.major_version == 3: + libname = 'pypy3-c' + else: + libname = 'pypy-c' + libdir = os.path.join(self.variables.get('base'), 'bin') + libdirs = [libdir] + else: + libname = f'python{self.version}' + if 'DEBUG_EXT' in self.variables: + libname += self.variables['DEBUG_EXT'] + if 'ABIFLAGS' in self.variables: + libname += self.variables['ABIFLAGS'] + libdirs = [] + + largs = self.clib_compiler.find_library(libname, environment, libdirs) + if largs is not None: + self.link_args = largs + + self.is_found = largs is not None or self.link_libpython + + inc_paths = mesonlib.OrderedSet([ + self.variables.get('INCLUDEPY'), + self.paths.get('include'), + self.paths.get('platinclude')]) + + self.compile_args += ['-I' + path for path in inc_paths if path] + + def get_windows_python_arch(self): + if self.platform == 'mingw': + pycc = self.variables.get('CC') + if pycc.startswith('x86_64'): + return '64' + elif pycc.startswith(('i686', 'i386')): + return '32' + else: + mlog.log('MinGW Python built with unknown CC {!r}, please file' + 'a bug'.format(pycc)) + return None + elif self.platform == 'win32': + return '32' + elif self.platform in ('win64', 'win-amd64'): + return '64' + mlog.log(f'Unknown Windows Python platform {self.platform!r}') + return None + + def get_windows_link_args(self): + if self.platform.startswith('win'): + vernum = self.variables.get('py_version_nodot') + if self.static: + libpath = Path('libs') / f'libpython{vernum}.a' + else: + comp = self.get_compiler() + if comp.id == "gcc": + libpath = f'python{vernum}.dll' + else: + libpath = Path('libs') / f'python{vernum}.lib' + lib = Path(self.variables.get('base')) / libpath + elif self.platform == 'mingw': + if self.static: + libname = self.variables.get('LIBRARY') + else: + libname = self.variables.get('LDLIBRARY') + lib = Path(self.variables.get('LIBDIR')) / libname + if not lib.exists(): + mlog.log('Could not find Python3 library {!r}'.format(str(lib))) + return None + return [str(lib)] + + def _find_libpy_windows(self, env): + ''' + Find python3 libraries on Windows and also verify that the arch matches + what we are building for. + ''' + pyarch = self.get_windows_python_arch() + if pyarch is None: + self.is_found = False + return + arch = detect_cpu_family(env.coredata.compilers.host) + if arch == 'x86': + arch = '32' + elif arch == 'x86_64': + arch = '64' + else: + # We can't cross-compile Python 3 dependencies on Windows yet + mlog.log(f'Unknown architecture {arch!r} for', + mlog.bold(self.name)) + self.is_found = False + return + # Pyarch ends in '32' or '64' + if arch != pyarch: + mlog.log('Need', mlog.bold(self.name), 'for {}-bit, but ' + 'found {}-bit'.format(arch, pyarch)) + self.is_found = False + return + # This can fail if the library is not found + largs = self.get_windows_link_args() + if largs is None: + self.is_found = False + return + self.link_args = largs + # Compile args + inc_paths = mesonlib.OrderedSet([ + self.variables.get('INCLUDEPY'), + self.paths.get('include'), + self.paths.get('platinclude')]) + + self.compile_args += ['-I' + path for path in inc_paths if path] + + # https://sourceforge.net/p/mingw-w64/mailman/message/30504611/ + if pyarch == '64' and self.major_version == 2: + self.compile_args += ['-DMS_WIN64'] + + self.is_found = True + + @staticmethod + def get_methods(): + if mesonlib.is_windows(): + return [DependencyMethods.PKGCONFIG, DependencyMethods.SYSCONFIG] + elif mesonlib.is_osx(): + return [DependencyMethods.PKGCONFIG, DependencyMethods.EXTRAFRAMEWORK] + else: + return [DependencyMethods.PKGCONFIG, DependencyMethods.SYSCONFIG] + + def get_pkgconfig_variable(self, variable_name, kwargs): + if self.pkgdep: + return self.pkgdep.get_pkgconfig_variable(variable_name, kwargs) + else: + return super().get_pkgconfig_variable(variable_name, kwargs) + + +INTROSPECT_COMMAND = '''import sysconfig +import json +import sys + +install_paths = sysconfig.get_paths(scheme='posix_prefix', vars={'base': '', 'platbase': '', 'installed_base': ''}) + +def links_against_libpython(): + from distutils.core import Distribution, Extension + cmd = Distribution().get_command_obj('build_ext') + cmd.ensure_finalized() + return bool(cmd.get_libraries(Extension('dummy', []))) + +print (json.dumps ({ + 'variables': sysconfig.get_config_vars(), + 'paths': sysconfig.get_paths(), + 'install_paths': install_paths, + 'sys_paths': sys.path, + 'version': sysconfig.get_python_version(), + 'platform': sysconfig.get_platform(), + 'is_pypy': '__pypy__' in sys.builtin_module_names, + 'link_libpython': links_against_libpython(), +})) +''' + +if T.TYPE_CHECKING: + class PythonIntrospectionDict(TypedDict): + + install_paths: T.Dict[str, str] + is_pypy: bool + link_libpython: bool + paths: T.Dict[str, str] + platform: str + suffix : str + variables: T.Dict[str, str] + version: str + +class PythonExternalProgram(ExternalProgram): + def __init__(self, name: str, command: T.Optional[T.List[str]] = None, ext_prog: T.Optional[ExternalProgram] = None): + if ext_prog is None: + super().__init__(name, command=command, silent=True) + else: + self.name = name + self.command = ext_prog.command + self.path = ext_prog.path + self.info: 'PythonIntrospectionDict' = { + 'install_paths': {}, + 'is_pypy': False, + 'link_libpython': False, + 'paths': {}, + 'platform': 'sentinal', + 'variables': {}, + 'version': '0.0', + } + + def _check_version(self, version: str) -> bool: + if self.name == 'python2': + return mesonlib.version_compare(version, '< 3.0') + elif self.name == 'python3': + return mesonlib.version_compare(version, '>= 3.0') + return True + + def sanity(self) -> bool: + # Sanity check, we expect to have something that at least quacks in tune + cmd = self.get_command() + ['-c', INTROSPECT_COMMAND] + p, stdout, stderr = mesonlib.Popen_safe(cmd) + try: + info = json.loads(stdout) + except json.JSONDecodeError: + info = None + mlog.debug('Could not introspect Python (%s): exit code %d' % (str(p.args), p.returncode)) + mlog.debug('Program stdout:\n') + mlog.debug(stdout) + mlog.debug('Program stderr:\n') + mlog.debug(stderr) + + if info is not None and self._check_version(info['version']): + variables = info['variables'] + info['suffix'] = variables.get('EXT_SUFFIX') or variables.get('SO') or variables.get('.so') + self.info = T.cast('PythonIntrospectionDict', info) + self.platlib = self._get_path('platlib') + self.purelib = self._get_path('purelib') + return True + else: + return False + + def _get_path(self, key: str) -> None: + user_dir = str(Path.home()) + sys_paths = self.info['sys_paths'] + rel_path = self.info['install_paths'][key][1:] + if not any(p.endswith(rel_path) for p in sys_paths if not p.startswith(user_dir)): + # On Debian derivatives sysconfig install path is broken and is not + # included in the locations python actually lookup. + # See https://github.com/mesonbuild/meson/issues/8739. + mlog.warning('Broken python installation detected. Python files', + 'installed by Meson might not be found by python interpreter.', + once=True) + if mesonlib.is_debianlike(): + rel_path = 'lib/python3/dist-packages' + return rel_path + + +class PythonInstallation(ExternalProgramHolder): + def __init__(self, python, interpreter): + ExternalProgramHolder.__init__(self, python, interpreter) + info = python.info + prefix = self.interpreter.environment.coredata.get_option(mesonlib.OptionKey('prefix')) + self.variables = info['variables'] + self.suffix = info['suffix'] + self.paths = info['paths'] + self.platlib_install_path = os.path.join(prefix, python.platlib) + self.purelib_install_path = os.path.join(prefix, python.purelib) + self.version = info['version'] + self.platform = info['platform'] + self.is_pypy = info['is_pypy'] + self.link_libpython = info['link_libpython'] + self.methods.update({ + 'extension_module': self.extension_module_method, + 'dependency': self.dependency_method, + 'install_sources': self.install_sources_method, + 'get_install_dir': self.get_install_dir_method, + 'language_version': self.language_version_method, + 'found': self.found_method, + 'has_path': self.has_path_method, + 'get_path': self.get_path_method, + 'has_variable': self.has_variable_method, + 'get_variable': self.get_variable_method, + 'path': self.path_method, + }) + + @permittedKwargs(mod_kwargs) + def extension_module_method(self, args, kwargs): + if 'install_dir' in kwargs: + if 'subdir' in kwargs: + raise InvalidArguments('"subdir" and "install_dir" are mutually exclusive') + else: + subdir = kwargs.pop('subdir', '') + if not isinstance(subdir, str): + raise InvalidArguments('"subdir" argument must be a string.') + + kwargs['install_dir'] = os.path.join(self.platlib_install_path, subdir) + + # On macOS and some Linux distros (Debian) distutils doesn't link + # extensions against libpython. We call into distutils and mirror its + # behavior. See https://github.com/mesonbuild/meson/issues/4117 + if not self.link_libpython: + new_deps = [] + for dep in mesonlib.extract_as_list(kwargs, 'dependencies'): + if isinstance(dep, PythonDependency): + dep = dep.get_partial_dependency(compile_args=True) + new_deps.append(dep) + kwargs['dependencies'] = new_deps + + # msys2's python3 has "-cpython-36m.dll", we have to be clever + # FIXME: explain what the specific cleverness is here + split, suffix = self.suffix.rsplit('.', 1) + args[0] += split + + kwargs['name_prefix'] = '' + kwargs['name_suffix'] = suffix + + return self.interpreter.func_shared_module(None, args, kwargs) + + @permittedKwargs(permitted_dependency_kwargs | {'embed'}) + @FeatureNewKwargs('python_installation.dependency', '0.53.0', ['embed']) + def dependency_method(self, args, kwargs): + if args: + mlog.warning('python_installation.dependency() does not take any ' + 'positional arguments. It always returns a Python ' + 'dependency. This will become an error in the future.', + location=self.interpreter.current_node) + disabled, required, feature = extract_required_kwarg(kwargs, self.subproject) + if disabled: + mlog.log('Dependency', mlog.bold('python'), 'skipped: feature', mlog.bold(feature), 'disabled') + dep = NotFoundDependency(self.interpreter.environment) + else: + dep = PythonDependency(self, self.interpreter.environment, kwargs) + if required and not dep.found(): + raise mesonlib.MesonException('Python dependency not found') + return dep + + @permittedKwargs(['pure', 'subdir']) + def install_sources_method(self, args, kwargs): + pure = kwargs.pop('pure', True) + if not isinstance(pure, bool): + raise InvalidArguments('"pure" argument must be a boolean.') + + subdir = kwargs.pop('subdir', '') + if not isinstance(subdir, str): + raise InvalidArguments('"subdir" argument must be a string.') + + if pure: + kwargs['install_dir'] = os.path.join(self.purelib_install_path, subdir) + else: + kwargs['install_dir'] = os.path.join(self.platlib_install_path, subdir) + + return self.interpreter.func_install_data(None, args, kwargs) + + @noPosargs + @permittedKwargs(['pure', 'subdir']) + def get_install_dir_method(self, args, kwargs): + pure = kwargs.pop('pure', True) + if not isinstance(pure, bool): + raise InvalidArguments('"pure" argument must be a boolean.') + + subdir = kwargs.pop('subdir', '') + if not isinstance(subdir, str): + raise InvalidArguments('"subdir" argument must be a string.') + + if pure: + res = os.path.join(self.purelib_install_path, subdir) + else: + res = os.path.join(self.platlib_install_path, subdir) + + return res + + @noPosargs + @noKwargs + def language_version_method(self, args, kwargs): + return self.version + + @noKwargs + def has_path_method(self, args, kwargs): + if len(args) != 1: + raise InvalidArguments('has_path takes exactly one positional argument.') + path_name = args[0] + if not isinstance(path_name, str): + raise InvalidArguments('has_path argument must be a string.') + + return path_name in self.paths + + @noKwargs + def get_path_method(self, args, kwargs): + if len(args) not in (1, 2): + raise InvalidArguments('get_path must have one or two arguments.') + path_name = args[0] + if not isinstance(path_name, str): + raise InvalidArguments('get_path argument must be a string.') + + try: + path = self.paths[path_name] + except KeyError: + if len(args) == 2: + path = args[1] + else: + raise InvalidArguments(f'{path_name} is not a valid path name') + + return path + + @noKwargs + def has_variable_method(self, args, kwargs): + if len(args) != 1: + raise InvalidArguments('has_variable takes exactly one positional argument.') + var_name = args[0] + if not isinstance(var_name, str): + raise InvalidArguments('has_variable argument must be a string.') + + return var_name in self.variables + + @noKwargs + def get_variable_method(self, args, kwargs): + if len(args) not in (1, 2): + raise InvalidArguments('get_variable must have one or two arguments.') + var_name = args[0] + if not isinstance(var_name, str): + raise InvalidArguments('get_variable argument must be a string.') + + try: + var = self.variables[var_name] + except KeyError: + if len(args) == 2: + var = args[1] + else: + raise InvalidArguments(f'{var_name} is not a valid variable name') + + return var + + @noPosargs + @noKwargs + @FeatureNew('Python module path method', '0.50.0') + def path_method(self, args, kwargs): + return super().path_method(args, kwargs) + + +class PythonModule(ExtensionModule): + + @FeatureNew('Python Module', '0.46.0') + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.methods.update({ + 'find_installation': self.find_installation, + }) + + # https://www.python.org/dev/peps/pep-0397/ + def _get_win_pythonpath(self, name_or_path): + if name_or_path not in ['python2', 'python3']: + return None + if not shutil.which('py'): + # program not installed, return without an exception + return None + ver = {'python2': '-2', 'python3': '-3'}[name_or_path] + cmd = ['py', ver, '-c', "import sysconfig; print(sysconfig.get_config_var('BINDIR'))"] + _, stdout, _ = mesonlib.Popen_safe(cmd) + directory = stdout.strip() + if os.path.exists(directory): + return os.path.join(directory, 'python') + else: + return None + + + @FeatureNewKwargs('python.find_installation', '0.49.0', ['disabler']) + @FeatureNewKwargs('python.find_installation', '0.51.0', ['modules']) + @disablerIfNotFound + @permittedKwargs({'required', 'modules'}) + def find_installation(self, state, args, kwargs): + feature_check = FeatureNew('Passing "feature" option to find_installation', '0.48.0') + disabled, required, feature = extract_required_kwarg(kwargs, state.subproject, feature_check) + want_modules = mesonlib.extract_as_list(kwargs, 'modules') # type: T.List[str] + found_modules = [] # type: T.List[str] + missing_modules = [] # type: T.List[str] + fallback = args[0] if args else '' + display_name = fallback or 'python' + + if len(args) > 1: + raise InvalidArguments('find_installation takes zero or one positional argument.') + + name_or_path = state.environment.lookup_binary_entry(MachineChoice.HOST, 'python') + if name_or_path is None and args: + name_or_path = fallback + if not isinstance(name_or_path, str): + raise InvalidArguments('find_installation argument must be a string.') + + if disabled: + mlog.log('Program', name_or_path or 'python', 'found:', mlog.red('NO'), '(disabled by:', mlog.bold(feature), ')') + return NonExistingExternalProgram() + + if not name_or_path: + python = PythonExternalProgram('python3', mesonlib.python_command) + else: + tmp_python = ExternalProgram.from_entry(display_name, name_or_path) + python = PythonExternalProgram(display_name, ext_prog=tmp_python) + + if not python.found() and mesonlib.is_windows(): + pythonpath = self._get_win_pythonpath(name_or_path) + if pythonpath is not None: + name_or_path = pythonpath + python = PythonExternalProgram(name_or_path) + + # Last ditch effort, python2 or python3 can be named python + # on various platforms, let's not give up just yet, if an executable + # named python is available and has a compatible version, let's use + # it + if not python.found() and name_or_path in ['python2', 'python3']: + python = PythonExternalProgram('python') + + if python.found() and want_modules: + for mod in want_modules: + p, out, err = mesonlib.Popen_safe( + python.command + + ['-c', f'import {mod}']) + if p.returncode != 0: + missing_modules.append(mod) + else: + found_modules.append(mod) + + msg = ['Program', python.name] + if want_modules: + msg.append('({})'.format(', '.join(want_modules))) + msg.append('found:') + if python.found() and not missing_modules: + msg.extend([mlog.green('YES'), '({})'.format(' '.join(python.command))]) + else: + msg.append(mlog.red('NO')) + if found_modules: + msg.append('modules:') + msg.append(', '.join(found_modules)) + + mlog.log(*msg) + + if not python.found(): + if required: + raise mesonlib.MesonException('{} not found'.format(name_or_path or 'python')) + return NonExistingExternalProgram() + elif missing_modules: + if required: + raise mesonlib.MesonException('{} is missing modules: {}'.format(name_or_path or 'python', ', '.join(missing_modules))) + return NonExistingExternalProgram() + else: + sane = python.sanity() + + if sane: + return python + else: + if required: + raise mesonlib.MesonException(f'{python} is not a valid python or it is missing distutils') + return NonExistingExternalProgram() + + raise mesonlib.MesonBugException('Unreachable code was reached (PythonModule.find_installation).') + + +def initialize(*args, **kwargs): + mod = PythonModule(*args, **kwargs) + mod.interpreter.append_holder_map(PythonExternalProgram, PythonInstallation) + return mod |