summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFilipe Laíns <lains@riseup.net>2024-11-22 23:13:43 +0000
committerJussi Pakkanen <jussi.pakkanen@mailbox.org>2025-08-28 22:09:20 +0300
commit59c3dd1fdf1677e3754449314337277083327b03 (patch)
tree200e389f02d1b37e0f2eccd1987650edf029534c
parente38545b00061dafddd601a1959946ab3a531ba13 (diff)
downloadmeson-59c3dd1fdf1677e3754449314337277083327b03.tar.gz
python: add a python.build_config option (PEP 739)
Signed-off-by: Filipe Laíns <lains@riseup.net> Signed-off-by: Michał Górny <mgorny@quansight.com>
-rw-r--r--docs/markdown/Builtin-options.md1
-rw-r--r--mesonbuild/dependencies/pkgconfig.py28
-rw-r--r--mesonbuild/dependencies/python.py279
-rw-r--r--mesonbuild/modules/python.py34
-rw-r--r--mesonbuild/options.py1
-rw-r--r--test cases/unit/125 python extension/foo.c31
-rw-r--r--test cases/unit/125 python extension/meson.build20
-rw-r--r--unittests/allplatformstests.py116
8 files changed, 421 insertions, 89 deletions
diff --git a/docs/markdown/Builtin-options.md b/docs/markdown/Builtin-options.md
index c23eaae3a..e52ba3f5d 100644
--- a/docs/markdown/Builtin-options.md
+++ b/docs/markdown/Builtin-options.md
@@ -444,6 +444,7 @@ install prefix. For example: if the install prefix is `/usr` and the
| platlibdir | | Directory path | Directory for site-specific, platform-specific files (Since 0.60.0) |
| purelibdir | | Directory path | Directory for site-specific, non-platform-specific files (Since 0.60.0) |
| allow_limited_api | true | true, false | Disables project-wide use of the Python Limited API (Since 1.3.0) |
+| build_config | | File path | Specifies the Python build configuration file (PEP 739) (Since 1.9.0) |
*Since 0.60.0* The `python.platlibdir` and `python.purelibdir` options are used
by the python module methods `python.install_sources()` and
diff --git a/mesonbuild/dependencies/pkgconfig.py b/mesonbuild/dependencies/pkgconfig.py
index 94e089356..a0727f60f 100644
--- a/mesonbuild/dependencies/pkgconfig.py
+++ b/mesonbuild/dependencies/pkgconfig.py
@@ -41,12 +41,13 @@ class PkgConfigInterface:
PkgConfigInterface.pkg_bin_per_machine[for_machine] = pkg_bin
@staticmethod
- def instance(env: Environment, for_machine: MachineChoice, silent: bool) -> T.Optional[PkgConfigInterface]:
+ def instance(env: Environment, for_machine: MachineChoice, silent: bool,
+ extra_paths: T.Optional[T.List[str]] = None) -> T.Optional[PkgConfigInterface]:
'''Return a pkg-config implementation singleton'''
for_machine = for_machine if env.is_cross_build() else MachineChoice.HOST
impl = PkgConfigInterface.class_impl[for_machine]
if impl is False:
- impl = PkgConfigCLI(env, for_machine, silent, PkgConfigInterface.pkg_bin_per_machine[for_machine])
+ impl = PkgConfigCLI(env, for_machine, silent, PkgConfigInterface.pkg_bin_per_machine[for_machine], extra_paths)
if not impl.found():
impl = None
if not impl and not silent:
@@ -55,7 +56,9 @@ class PkgConfigInterface:
return impl
@staticmethod
- def _cli(env: Environment, for_machine: MachineChoice, silent: bool = False) -> T.Optional[PkgConfigCLI]:
+ def _cli(env: Environment, for_machine: MachineChoice,
+ extra_paths: T.Optional[T.List[str]] = None,
+ silent: bool = False) -> T.Optional[PkgConfigCLI]:
'''Return the CLI pkg-config implementation singleton
Even when we use another implementation internally, external tools might
still need the CLI implementation.
@@ -66,15 +69,16 @@ class PkgConfigInterface:
if impl and not isinstance(impl, PkgConfigCLI):
impl = PkgConfigInterface.class_cli_impl[for_machine]
if impl is False:
- impl = PkgConfigCLI(env, for_machine, silent, PkgConfigInterface.pkg_bin_per_machine[for_machine])
+ impl = PkgConfigCLI(env, for_machine, silent, PkgConfigInterface.pkg_bin_per_machine[for_machine], extra_paths)
if not impl.found():
impl = None
PkgConfigInterface.class_cli_impl[for_machine] = impl
return T.cast('T.Optional[PkgConfigCLI]', impl) # Trust me, mypy
@staticmethod
- def get_env(env: Environment, for_machine: MachineChoice, uninstalled: bool = False) -> EnvironmentVariables:
- cli = PkgConfigInterface._cli(env, for_machine)
+ def get_env(env: Environment, for_machine: MachineChoice, uninstalled: bool = False,
+ extra_paths: T.Optional[T.List[str]] = None) -> EnvironmentVariables:
+ cli = PkgConfigInterface._cli(env, for_machine, extra_paths)
return cli._get_env(uninstalled) if cli else EnvironmentVariables()
@staticmethod
@@ -123,11 +127,13 @@ class PkgConfigCLI(PkgConfigInterface):
'''pkg-config CLI implementation'''
def __init__(self, env: Environment, for_machine: MachineChoice, silent: bool,
- pkgbin: T.Optional[ExternalProgram] = None) -> None:
+ pkgbin: T.Optional[ExternalProgram] = None,
+ extra_paths: T.Optional[T.List[str]] = None) -> None:
super().__init__(env, for_machine)
self._detect_pkgbin(pkgbin)
if self.pkgbin and not silent:
mlog.log('Found pkg-config:', mlog.green('YES'), mlog.bold(f'({self.pkgbin.get_path()})'), mlog.blue(self.pkgbin_version))
+ self.extra_paths = extra_paths or []
def found(self) -> bool:
return bool(self.pkgbin)
@@ -258,7 +264,7 @@ class PkgConfigCLI(PkgConfigInterface):
key = OptionKey('pkg_config_path', machine=self.for_machine)
pathlist = self.env.coredata.optstore.get_value_for(key)
assert isinstance(pathlist, list)
- extra_paths: T.List[str] = pathlist[:]
+ extra_paths: T.List[str] = pathlist + self.extra_paths
if uninstalled:
bpath = self.env.get_build_dir()
if bpath is not None:
@@ -297,11 +303,13 @@ class PkgConfigCLI(PkgConfigInterface):
class PkgConfigDependency(ExternalDependency):
def __init__(self, name: str, environment: Environment, kwargs: T.Dict[str, T.Any],
- language: T.Optional[str] = None) -> None:
+ language: T.Optional[str] = None,
+ extra_paths: T.Optional[T.List[str]] = None) -> None:
super().__init__(DependencyTypeName('pkgconfig'), environment, kwargs, language=language)
self.name = name
self.is_libtool = False
- pkgconfig = PkgConfigInterface.instance(self.env, self.for_machine, self.silent)
+ self.extra_paths = extra_paths or []
+ pkgconfig = PkgConfigInterface.instance(self.env, self.for_machine, self.silent, self.extra_paths)
if not pkgconfig:
msg = f'Pkg-config for machine {self.for_machine} not found. Giving up.'
if self.required:
diff --git a/mesonbuild/dependencies/python.py b/mesonbuild/dependencies/python.py
index b028d9f63..652947869 100644
--- a/mesonbuild/dependencies/python.py
+++ b/mesonbuild/dependencies/python.py
@@ -3,12 +3,12 @@
from __future__ import annotations
-import functools, json, os, textwrap
+import functools, json, operator, os, textwrap
from pathlib import Path
import typing as T
from .. import mesonlib, mlog
-from .base import process_method_kw, DependencyException, DependencyMethods, DependencyTypeName, ExternalDependency, SystemDependency
+from .base import process_method_kw, DependencyException, DependencyMethods, ExternalDependency, SystemDependency
from .configtool import ConfigToolDependency
from .detect import packages
from .factory import DependencyFactory
@@ -19,7 +19,7 @@ from ..programs import ExternalProgram
from ..options import OptionKey
if T.TYPE_CHECKING:
- from typing_extensions import TypedDict
+ from typing_extensions import Final, TypedDict
from .factory import DependencyGenerator
from ..environment import Environment
@@ -74,9 +74,94 @@ class NumPyConfigToolDependency(ConfigToolDependency):
self.compile_args = self.get_config_value(['--cflags'], 'compile_args')
+class PythonBuildConfig:
+ """PEP 739 build-details.json config file."""
+
+ IMPLEMENTED_VERSION: Final[str] = '1.0'
+ """Schema version currently implemented."""
+ _PATH_KEYS = (
+ 'base_interpreter',
+ 'libpython.dynamic',
+ 'libpython.dynamic_stableabi',
+ 'libpython.static',
+ 'c_api.headers',
+ 'c_api.pkgconfig_path',
+ )
+ """Path keys — may be relative, need to be expanded."""
+
+ def __init__(self, path: str) -> None:
+ self._path = Path(path)
+
+ try:
+ self._data = json.loads(self._path.read_text(encoding='utf8'))
+ except OSError as e:
+ raise DependencyException(f'Failed to read python.build_config: {e}') from e
+
+ self._validate_data()
+ self._expand_paths()
+
+ def __getitem__(self, key: str) -> T.Any:
+ return functools.reduce(operator.getitem, key.split('.'), self._data)
+
+ def __contains__(self, key: str) -> bool:
+ try:
+ self[key]
+ except KeyError:
+ return False
+ else:
+ return True
+
+ def get(self, key: str, default: T.Any = None) -> T.Any:
+ try:
+ return self[key]
+ except KeyError:
+ return default
+
+ def _validate_data(self) -> None:
+ schema_version = self._data['schema_version']
+ if mesonlib.version_compare(schema_version, '< 1.0'):
+ raise DependencyException(f'Invalid schema_version in python.build_config: {schema_version}')
+ if mesonlib.version_compare(schema_version, '>= 2.0'):
+ raise DependencyException(
+ f'Unsupported schema_version {schema_version!r} in python.build_config, '
+ f'but we only implement support for {self.IMPLEMENTED_VERSION!r}'
+ )
+ # Schema version that we currently understand
+ if mesonlib.version_compare(schema_version, f'> {self.IMPLEMENTED_VERSION}'):
+ mlog.log(
+ f'python.build_config has schema_version {schema_version!r}, '
+ f'but we only implement support for {self.IMPLEMENTED_VERSION!r}, '
+ 'new functionality might be missing'
+ )
+
+ def _expand_paths(self) -> None:
+ """Expand relative path (they're relative to base_prefix)."""
+ for key in self._PATH_KEYS:
+ if key not in self:
+ continue
+ parent, _, child = key.rpartition('.')
+ container = self[parent] if parent else self._data
+ path = Path(container[child])
+ if not path.is_absolute():
+ container[child] = os.fspath(self.base_prefix / path)
+
+ @property
+ def config_path(self) -> Path:
+ return self._path
+
+ @mesonlib.lazy_property
+ def base_prefix(self) -> Path:
+ path = Path(self._data['base_prefix'])
+ if path.is_absolute():
+ return path
+ # Non-absolute paths are relative to the build config directory
+ return self.config_path.parent / path
+
+
class BasicPythonExternalProgram(ExternalProgram):
def __init__(self, name: str, command: T.Optional[T.List[str]] = None,
- ext_prog: T.Optional[ExternalProgram] = None):
+ ext_prog: T.Optional[ExternalProgram] = None,
+ build_config_path: T.Optional[str] = None):
if ext_prog is None:
super().__init__(name, command=command, silent=True)
else:
@@ -86,6 +171,8 @@ class BasicPythonExternalProgram(ExternalProgram):
self.cached_version = None
self.version_arg = '--version'
+ self.build_config = PythonBuildConfig(build_config_path) if build_config_path else None
+
# We want strong key values, so we always populate this with bogus data.
# Otherwise to make the type checkers happy we'd have to do .get() for
# everycall, even though we know that the introspection data will be
@@ -106,6 +193,15 @@ class BasicPythonExternalProgram(ExternalProgram):
}
self.pure: bool = True
+ @property
+ def version(self) -> str:
+ if self.build_config:
+ value = self.build_config['language']['version']
+ else:
+ value = self.info['variables'].get('LDVERSION') or self.info['version']
+ assert isinstance(value, str)
+ return value
+
def _check_version(self, version: str) -> bool:
if self.name == 'python2':
return mesonlib.version_compare(version, '< 3.0')
@@ -116,6 +212,14 @@ class BasicPythonExternalProgram(ExternalProgram):
def sanity(self) -> bool:
# Sanity check, we expect to have something that at least quacks in tune
+ if self.build_config:
+ if not self.build_config['libpython']:
+ mlog.debug('This Python installation does not provide a libpython')
+ return False
+ if not self.build_config['c_api']:
+ mlog.debug('This Python installation does support the C API')
+ return False
+
import importlib.resources
with importlib.resources.path('mesonbuild.scripts', 'python_info.py') as f:
@@ -143,14 +247,32 @@ class BasicPythonExternalProgram(ExternalProgram):
class _PythonDependencyBase(_Base):
- def __init__(self, python_holder: 'BasicPythonExternalProgram', embed: bool):
+ def __init__(self, python_holder: 'BasicPythonExternalProgram', embed: bool,
+ for_machine: 'MachineChoice'):
+ self.for_machine = for_machine
self.embed = embed
- self.version: str = python_holder.info['version']
- self.platform = python_holder.info['platform']
- self.variables = python_holder.info['variables']
+ self.build_config = python_holder.build_config
+
+ if self.build_config:
+ self.version = self.build_config['language']['version']
+ self.platform = self.build_config['platform']
+ self.is_freethreaded = 't' in self.build_config['abi']['flags']
+ self.link_libpython = self.build_config['libpython']['link_extensions']
+ # TODO: figure out how to deal with frameworks
+ # see the logic at the bottom of PythonPkgConfigDependency.__init__()
+ if self.env.machines.host.is_darwin():
+ raise DependencyException('--python.build-config is not supported on Darwin')
+ else:
+ self.version = python_holder.info['version']
+ self.platform = python_holder.info['platform']
+ self.is_freethreaded = python_holder.info['is_freethreaded']
+ self.link_libpython = python_holder.info['link_libpython']
+ # This data shouldn't be needed when build_config is set
+ self.is_pypy = python_holder.info['is_pypy']
+ self.variables = python_holder.info['variables']
+
self.paths = python_holder.info['paths']
- self.is_pypy = python_holder.info['is_pypy']
- self.is_freethreaded = python_holder.info['is_freethreaded']
+
# The "-embed" version of python.pc / python-config was introduced in 3.8,
# and distutils extension linking was changed to be considered a non embed
# usage. Before then, this dependency always uses the embed=True handling
@@ -159,7 +281,9 @@ class _PythonDependencyBase(_Base):
# On macOS and some Linux distros (Debian) distutils doesn't link extensions
# against libpython, even on 3.7 and below. We call into distutils and
# mirror its behavior. See https://github.com/mesonbuild/meson/issues/4117
- self.link_libpython = python_holder.info['link_libpython'] or embed
+ if not self.link_libpython:
+ self.link_libpython = embed
+
self.info: T.Optional[T.Dict[str, str]] = None
if mesonlib.version_compare(self.version, '>= 3.0'):
self.major_version = 3
@@ -173,6 +297,18 @@ class _PythonDependencyBase(_Base):
self.compile_args += ['-DPy_GIL_DISABLED']
def find_libpy(self, environment: 'Environment') -> None:
+ if self.build_config:
+ path = self.build_config['libpython'].get('dynamic')
+ if not path:
+ raise DependencyException('Python does not provide a dynamic libpython library')
+ sysroot = environment.properties[self.for_machine].get_sys_root() or ''
+ path = sysroot + path
+ if not os.path.isfile(path):
+ raise DependencyException('Python dynamic library does not exist or is not a file')
+ self.link_args = [path]
+ self.is_found = True
+ return
+
if self.is_pypy:
if self.major_version == 3:
libname = 'pypy3-c'
@@ -211,7 +347,17 @@ class _PythonDependencyBase(_Base):
return 'aarch64'
raise DependencyException('Unknown Windows Python platform {self.platform!r}')
- def get_windows_link_args(self, limited_api: bool) -> T.Optional[T.List[str]]:
+ def get_windows_link_args(self, limited_api: bool, environment: 'Environment') -> T.Optional[T.List[str]]:
+ if self.build_config:
+ if self.static:
+ key = 'static'
+ elif limited_api:
+ key = 'dynamic-stableabi'
+ else:
+ key = 'dynamic'
+ sysroot = environment.properties[self.for_machine].get_sys_root() or ''
+ return [sysroot + self.build_config['libpython'][key]]
+
if self.platform.startswith('win'):
vernum = self.variables.get('py_version_nodot')
verdot = self.variables.get('py_version_short')
@@ -300,28 +446,49 @@ class _PythonDependencyBase(_Base):
self.is_found = False
return
# This can fail if the library is not found
- largs = self.get_windows_link_args(limited_api)
+ largs = self.get_windows_link_args(limited_api, env)
if largs is None:
self.is_found = False
return
self.link_args = largs
self.is_found = True
+
class PythonPkgConfigDependency(PkgConfigDependency, _PythonDependencyBase):
- def __init__(self, name: str, environment: 'Environment',
- kwargs: T.Dict[str, T.Any], installation: 'BasicPythonExternalProgram',
- libpc: bool = False):
- if libpc:
- mlog.debug(f'Searching for {name!r} via pkgconfig lookup in LIBPC')
+ def __init__(self, environment: 'Environment', kwargs: T.Dict[str, T.Any],
+ installation: 'BasicPythonExternalProgram', embed: bool,
+ for_machine: 'MachineChoice'):
+ pkg_embed = '-embed' if embed and mesonlib.version_compare(installation.info['version'], '>=3.8') else ''
+ pkg_name = f'python-{installation.version}{pkg_embed}'
+
+ if installation.build_config:
+ pkg_libdir = installation.build_config.get('c_api.pkgconfig_path')
+ pkg_libdir_origin = 'c_api.pkgconfig_path from the Python build config'
else:
- mlog.debug(f'Searching for {name!r} via fallback pkgconfig lookup in default paths')
+ pkg_libdir = installation.info['variables'].get('LIBPC')
+ pkg_libdir_origin = 'LIBPC'
+ if pkg_libdir is None:
+ # we do not fall back to system directories, since this could lead
+ # to using pkg-config of another Python installation, for example
+ # we could end up using CPython .pc file for PyPy
+ mlog.debug(f'Skipping pkgconfig lookup, {pkg_libdir_origin} is unset')
+ self.is_found = False
+ return
- PkgConfigDependency.__init__(self, name, environment, kwargs)
- _PythonDependencyBase.__init__(self, installation, kwargs.get('embed', False))
+ sysroot = environment.properties[for_machine].get_sys_root() or ''
+ pkg_libdir = sysroot + pkg_libdir
- if libpc and not self.is_found:
- mlog.debug(f'"python-{self.version}" could not be found in LIBPC, this is likely due to a relocated python installation')
+ mlog.debug(f'Searching for {pkg_libdir!r} via pkgconfig lookup in {pkg_libdir_origin}')
+ pkgconfig_paths = [pkg_libdir] if pkg_libdir else []
+
+ PkgConfigDependency.__init__(self, pkg_name, environment, kwargs, extra_paths=pkgconfig_paths)
+ _PythonDependencyBase.__init__(self, installation, kwargs.get('embed', False), for_machine)
+
+ if pkg_libdir and not self.is_found:
+ mlog.debug(f'{pkg_name!r} could not be found in {pkg_libdir_origin}, '
+ 'this is likely due to a relocated python installation')
+ return
# pkg-config files are usually accurate starting with python 3.8
if not self.link_libpython and mesonlib.version_compare(self.version, '< 3.8'):
@@ -337,20 +504,23 @@ class PythonPkgConfigDependency(PkgConfigDependency, _PythonDependencyBase):
# When None, self.link_args is used
self.raw_link_args += ['-Wl,-rpath,' + framework_prefix]
+
class PythonFrameworkDependency(ExtraFrameworkDependency, _PythonDependencyBase):
def __init__(self, name: str, environment: 'Environment',
- kwargs: T.Dict[str, T.Any], installation: 'BasicPythonExternalProgram'):
+ kwargs: T.Dict[str, T.Any], installation: 'BasicPythonExternalProgram',
+ for_machine: 'MachineChoice'):
ExtraFrameworkDependency.__init__(self, name, environment, kwargs)
- _PythonDependencyBase.__init__(self, installation, kwargs.get('embed', False))
+ _PythonDependencyBase.__init__(self, installation, kwargs.get('embed', False), for_machine)
class PythonSystemDependency(SystemDependency, _PythonDependencyBase):
def __init__(self, name: str, environment: 'Environment',
- kwargs: T.Dict[str, T.Any], installation: 'BasicPythonExternalProgram'):
+ kwargs: T.Dict[str, T.Any], installation: 'BasicPythonExternalProgram',
+ for_machine: 'MachineChoice'):
SystemDependency.__init__(self, name, environment, kwargs)
- _PythonDependencyBase.__init__(self, installation, kwargs.get('embed', False))
+ _PythonDependencyBase.__init__(self, installation, kwargs.get('embed', False), for_machine)
# For most platforms, match pkg-config behavior. iOS is a special case;
# check for that first, so that check takes priority over
@@ -369,10 +539,14 @@ class PythonSystemDependency(SystemDependency, _PythonDependencyBase):
self.is_found = True
# compile args
- inc_paths = mesonlib.OrderedSet([
- self.variables.get('INCLUDEPY'),
- self.paths.get('include'),
- self.paths.get('platinclude')])
+ if self.build_config:
+ sysroot = environment.properties[for_machine].get_sys_root() or ''
+ inc_paths = mesonlib.OrderedSet([sysroot + self.build_config['c_api']['headers']])
+ else:
+ 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]
@@ -401,58 +575,23 @@ def python_factory(env: 'Environment', for_machine: 'MachineChoice',
if installation is None:
installation = BasicPythonExternalProgram('python3', mesonlib.python_command)
installation.sanity()
- pkg_version = installation.info['variables'].get('LDVERSION') or installation.info['version']
if DependencyMethods.PKGCONFIG in methods:
if from_installation:
- pkg_libdir = installation.info['variables'].get('LIBPC')
- pkg_embed = '-embed' if embed and mesonlib.version_compare(installation.info['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
- def wrap_in_pythons_pc_dir(name: str, env: 'Environment', kwargs: T.Dict[str, T.Any],
- installation: 'BasicPythonExternalProgram') -> 'ExternalDependency':
- if not pkg_libdir:
- # there is no LIBPC, so we can't search in it
- empty = ExternalDependency(DependencyTypeName('pkgconfig'), env, {})
- empty.name = 'python'
- return empty
-
- old_pkg_libdir = os.environ.pop('PKG_CONFIG_LIBDIR', None)
- old_pkg_path = os.environ.pop('PKG_CONFIG_PATH', None)
- os.environ['PKG_CONFIG_LIBDIR'] = pkg_libdir
- try:
- return PythonPkgConfigDependency(name, env, kwargs, installation, True)
- finally:
- def set_env(name: str, value: str) -> None:
- if value is not None:
- os.environ[name] = value
- elif name in os.environ:
- del os.environ[name]
- set_env('PKG_CONFIG_LIBDIR', old_pkg_libdir)
- set_env('PKG_CONFIG_PATH', old_pkg_path)
-
- # Otherwise this doesn't fulfill the interface requirements
- wrap_in_pythons_pc_dir.log_tried = PythonPkgConfigDependency.log_tried # type: ignore[attr-defined]
-
- candidates.append(functools.partial(wrap_in_pythons_pc_dir, pkg_name, env, kwargs, installation))
- # We only need to check both, if a python install has a LIBPC. It might point to the wrong location,
- # e.g. relocated / cross compilation, but the presence of LIBPC indicates we should definitely look for something.
- if pkg_libdir is not None:
- candidates.append(functools.partial(PythonPkgConfigDependency, pkg_name, env, kwargs, installation))
+ candidates.append(functools.partial(PythonPkgConfigDependency, env, kwargs, installation, embed, for_machine))
else:
candidates.append(functools.partial(PkgConfigDependency, 'python3', env, kwargs))
if DependencyMethods.SYSTEM in methods:
- candidates.append(functools.partial(PythonSystemDependency, 'python', env, kwargs, installation))
+ candidates.append(functools.partial(PythonSystemDependency, 'python', env, kwargs, installation, for_machine))
if DependencyMethods.EXTRAFRAMEWORK in methods:
nkwargs = kwargs.copy()
- if mesonlib.version_compare(pkg_version, '>= 3'):
+ if mesonlib.version_compare(installation.version, '>= 3'):
# There is a python in /System/Library/Frameworks, but that's python 2.x,
# Python 3 will always be in /Library
nkwargs['paths'] = ['/Library/Frameworks']
- candidates.append(functools.partial(PythonFrameworkDependency, 'Python', env, nkwargs, installation))
+ candidates.append(functools.partial(PythonFrameworkDependency, 'Python', env, nkwargs, installation, for_machine))
return candidates
diff --git a/mesonbuild/modules/python.py b/mesonbuild/modules/python.py
index 3c079609c..a266f3a3b 100644
--- a/mesonbuild/modules/python.py
+++ b/mesonbuild/modules/python.py
@@ -115,17 +115,27 @@ class PythonInstallation(_ExternalProgramHolder['PythonExternalProgram']):
info = python.info
prefix = self.interpreter.environment.coredata.optstore.get_value_for(OptionKey('prefix'))
assert isinstance(prefix, str), 'for mypy'
+
+ if python.build_config:
+ self.version = python.build_config['language']['version']
+ self.platform = python.build_config['platform']
+ self.suffix = python.build_config['abi']['extension_suffix']
+ self.limited_api_suffix = python.build_config['abi']['stable_abi_suffix']
+ self.link_libpython = python.build_config['libpython']['link_extensions']
+ self.is_pypy = python.build_config['implementation']['name'] == 'pypy'
+ else:
+ self.version = info['version']
+ self.platform = info['platform']
+ self.suffix = info['suffix']
+ self.limited_api_suffix = info['limited_api_suffix']
+ self.link_libpython = info['link_libpython']
+ self.is_pypy = info['is_pypy']
+
self.variables = info['variables']
- self.suffix = info['suffix']
- self.limited_api_suffix = info['limited_api_suffix']
self.paths = info['paths']
self.pure = python.pure
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']
@permittedKwargs(mod_kwargs)
@typed_pos_args('python.extension_module', str, varargs=(str, mesonlib.File, CustomTarget, CustomTargetIndex, GeneratedList, StructuredSources, ExtractedObjects, BuildTarget))
@@ -244,8 +254,12 @@ class PythonInstallation(_ExternalProgramHolder['PythonExternalProgram']):
if dep is not None:
return dep
+ build_config = self.interpreter.environment.coredata.optstore.get_value_for(OptionKey('python.build_config'))
+
new_kwargs = kwargs.copy()
new_kwargs['required'] = False
+ if build_config:
+ new_kwargs['build_config'] = build_config
candidates = python_factory(self.interpreter.environment, for_machine, new_kwargs, self.held_object)
dep = find_external_dependency('python', self.interpreter.environment, new_kwargs, candidates)
@@ -439,11 +453,13 @@ class PythonModule(ExtensionModule):
return None
def _find_installation_impl(self, state: 'ModuleState', display_name: str, name_or_path: str, required: bool) -> MaybePythonProg:
+ build_config = self.interpreter.environment.coredata.optstore.get_value_for(OptionKey('python.build_config'))
+
if not name_or_path:
- python = PythonExternalProgram('python3', mesonlib.python_command)
+ python = PythonExternalProgram('python3', mesonlib.python_command, build_config_path=build_config)
else:
tmp_python = ExternalProgram.from_entry(display_name, name_or_path)
- python = PythonExternalProgram(display_name, ext_prog=tmp_python)
+ python = PythonExternalProgram(display_name, ext_prog=tmp_python, build_config_path=build_config)
if not python.found() and mesonlib.is_windows():
pythonpath = self._get_win_pythonpath(name_or_path)
@@ -457,7 +473,7 @@ class PythonModule(ExtensionModule):
# it
if not python.found() and name_or_path in {'python2', 'python3'}:
tmp_python = ExternalProgram.from_entry(display_name, 'python')
- python = PythonExternalProgram(name_or_path, ext_prog=tmp_python)
+ python = PythonExternalProgram(name_or_path, ext_prog=tmp_python, build_config_path=build_config)
if python.found():
if python.sanity(state):
diff --git a/mesonbuild/options.py b/mesonbuild/options.py
index 346abcc3a..1b2bfc64e 100644
--- a/mesonbuild/options.py
+++ b/mesonbuild/options.py
@@ -745,6 +745,7 @@ BUILTIN_CORE_OPTIONS: T.Mapping[OptionKey, AnyOptionType] = {
UserStringOption('python.platlibdir', 'Directory for site-specific, platform-specific files.', ''),
UserStringOption('python.purelibdir', 'Directory for site-specific, non-platform-specific files.', ''),
UserBooleanOption('python.allow_limited_api', 'Whether to allow use of the Python Limited API', True),
+ UserStringOption('python.build_config', 'Config file containing the build details for the target Python installation.', ''),
])
}
diff --git a/test cases/unit/125 python extension/foo.c b/test cases/unit/125 python extension/foo.c
new file mode 100644
index 000000000..e1a0dfb7b
--- /dev/null
+++ b/test cases/unit/125 python extension/foo.c
@@ -0,0 +1,31 @@
+#define PY_SSIZE_T_CLEAN
+#include <Python.h>
+
+
+static PyObject *
+bar_impl(PyObject *self, PyObject *args)
+{
+ return Py_None;
+}
+
+
+static PyMethodDef foo_methods[] = {
+ {"bar", bar_impl, METH_NOARGS, NULL},
+ {NULL, NULL, 0, NULL} /* sentinel */
+};
+
+
+static struct PyModuleDef foo_module = {
+ PyModuleDef_HEAD_INIT,
+ "foo", /* m_name */
+ NULL, /* m_doc */
+ -1, /* m_size */
+ foo_methods, /* m_methods */
+};
+
+
+PyMODINIT_FUNC
+PyInit_foo(void)
+{
+ return PyModule_Create(&foo_module);
+}
diff --git a/test cases/unit/125 python extension/meson.build b/test cases/unit/125 python extension/meson.build
new file mode 100644
index 000000000..8cebceab7
--- /dev/null
+++ b/test cases/unit/125 python extension/meson.build
@@ -0,0 +1,20 @@
+project('python extension', 'c', meson_version : '>=1.3.0')
+
+py = import('python').find_installation('')
+
+py.extension_module(
+ 'foo', 'foo.c',
+ install: true,
+)
+
+limited_api_supported = true
+if py.language_version().version_compare('>=3.13') and py.language_version().version_compare('<3.15')
+ limited_api_supported = py.get_variable('Py_GIL_DISABLED') != 1
+endif
+if limited_api_supported
+ py.extension_module(
+ 'foo_stable', 'foo.c',
+ install: true,
+ limited_api: '3.2',
+ )
+endif
diff --git a/unittests/allplatformstests.py b/unittests/allplatformstests.py
index 8ebbab590..078ab96fe 100644
--- a/unittests/allplatformstests.py
+++ b/unittests/allplatformstests.py
@@ -13,6 +13,7 @@ import platform
import pickle
import zipfile, tarfile
import sys
+import sysconfig
from unittest import mock, SkipTest, skipIf, skipUnless, expectedFailure
from contextlib import contextmanager
from glob import glob
@@ -3055,6 +3056,121 @@ class AllPlatformTests(BasePlatformTests):
self.wipe()
self.init(testdir, extra_args=['-Dstart_native=true'], override_envvars=env)
+ @skipIf(is_osx(), 'Not implemented for Darwin yet')
+ @skipIf(is_windows(), 'POSIX only')
+ def test_python_build_config_extensions(self):
+ testdir = os.path.join(self.unit_test_dir,
+ '125 python extension')
+
+ VERSION_INFO_KEYS = ('major', 'minor', 'micro', 'releaselevel', 'serial')
+ EXTENSION_SUFFIX = '.extension-suffix.so'
+ STABLE_ABI_SUFFIX = '.stable-abi-suffix.so'
+ # macOS framework builds put libpython in PYTHONFRAMEWORKPREFIX.
+ LIBDIR = (sysconfig.get_config_var('PYTHONFRAMEWORKPREFIX') or
+ sysconfig.get_config_var('LIBDIR'))
+
+ python_build_config = {
+ 'schema_version': '1.0',
+ 'base_interpreter': sys.executable,
+ 'base_prefix': '/usr',
+ 'platform': sysconfig.get_platform(),
+ 'language': {
+ 'version': sysconfig.get_python_version(),
+ 'version_info': {key: getattr(sys.version_info, key) for key in VERSION_INFO_KEYS}
+ },
+ 'implementation': {
+ attr: (
+ getattr(sys.implementation, attr)
+ if attr != 'version' else
+ {key: getattr(sys.implementation.version, key) for key in VERSION_INFO_KEYS}
+ )
+ for attr in dir(sys.implementation)
+ if not attr.startswith('__')
+ },
+ 'abi': {
+ 'flags': list(sys.abiflags),
+ 'extension_suffix': EXTENSION_SUFFIX,
+ 'stable_abi_suffix': STABLE_ABI_SUFFIX,
+ },
+ 'suffixes': {
+ 'source': ['.py'],
+ 'bytecode': ['.pyc'],
+ 'optimized_bytecode': ['.pyc'],
+ 'debug_bytecode': ['.pyc'],
+ 'extensions': [EXTENSION_SUFFIX, STABLE_ABI_SUFFIX, '.so'],
+ },
+ 'libpython': {
+ 'dynamic': os.path.join(LIBDIR, sysconfig.get_config_var('LDLIBRARY')),
+ 'static': os.path.join(LIBDIR, sysconfig.get_config_var('LIBRARY')),
+ # set it to False on PyPy, since dylib is optional, but also
+ # the value is currently wrong:
+ # https://github.com/pypy/pypy/issues/5249
+ 'link_extensions': '__pypy__' not in sys.builtin_module_names,
+ },
+ 'c_api': {
+ 'headers': sysconfig.get_config_var('INCLUDEPY'),
+ }
+ }
+
+ py3library = sysconfig.get_config_var('PY3LIBRARY')
+ if py3library is not None:
+ python_build_config['libpython']['dynamic_stableabi'] = os.path.join(LIBDIR, py3library)
+
+ build_stable_abi = sysconfig.get_config_var('Py_GIL_DISABLED') != 1 or sys.version_info >= (3, 15)
+ intro_installed_file = os.path.join(self.builddir, 'meson-info', 'intro-installed.json')
+ expected_files = [
+ os.path.join(self.builddir, 'foo' + EXTENSION_SUFFIX),
+ ]
+ if build_stable_abi:
+ expected_files += [
+ os.path.join(self.builddir, 'foo_stable' + STABLE_ABI_SUFFIX),
+ ]
+ if is_cygwin():
+ expected_files += [
+ os.path.join(self.builddir, 'foo' + EXTENSION_SUFFIX.replace('.so', '.dll.a')),
+ ]
+ if build_stable_abi:
+ expected_files += [
+ os.path.join(self.builddir, 'foo_stable' + STABLE_ABI_SUFFIX.replace('.so', '.dll.a')),
+ ]
+
+ for with_pkgconfig in (False, True):
+ with self.subTest(with_pkgconfig=with_pkgconfig):
+ if with_pkgconfig:
+ libpc = sysconfig.get_config_var('LIBPC')
+ if libpc is None:
+ continue
+ python_build_config['c_api']['pkgconfig_path'] = libpc
+ # Old Ubuntu versions have incorrect LIBDIR, skip testing non-pkgconfig variant there.
+ elif not os.path.exists(python_build_config['libpython']['dynamic']):
+ continue
+
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as python_build_config_file:
+ json.dump(python_build_config, fp=python_build_config_file)
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as cross_file:
+ cross_file.write(
+ textwrap.dedent(f'''
+ [binaries]
+ pkg-config = 'pkg-config'
+
+ [built-in options]
+ python.build_config = '{python_build_config_file.name}'
+ '''.strip())
+ )
+ cross_file.flush()
+
+ for extra_args in (
+ ['--python.build-config', python_build_config_file.name],
+ ['--cross-file', cross_file.name],
+ ):
+ with self.subTest(extra_args=extra_args):
+ self.init(testdir, extra_args=extra_args)
+ self.build()
+ with open(intro_installed_file) as f:
+ intro_installed = json.load(f)
+ self.assertEqual(sorted(expected_files), sorted(intro_installed))
+ self.wipe()
+
def __reconfigure(self):
# Set an older version to force a reconfigure from scratch
filename = os.path.join(self.privatedir, 'coredata.dat')