From 19a90deb89bd7001a975b18c6637a621f931d0a1 Mon Sep 17 00:00:00 2001 From: Tobias Diez Date: Tue, 28 Apr 2026 21:40:34 +0200 Subject: [PATCH] Added support for `tool.meson-python.env` table in `pyproject.toml` Co-authored-by: Copilot --- docs/how-to-guides/editable-installs.rst | 5 ++ docs/reference/pyproject-settings.rst | 14 ++++++ mesonpy/__init__.py | 58 +++++++++++++++++------- mesonpy/_editable.py | 23 ++++++++-- tests/test_editable.py | 37 +++++++++++++-- tests/test_project.py | 50 ++++++++++++++++++++ 6 files changed, 163 insertions(+), 24 deletions(-) diff --git a/docs/how-to-guides/editable-installs.rst b/docs/how-to-guides/editable-installs.rst index 12cc8a4c1..7c3b3d8d5 100644 --- a/docs/how-to-guides/editable-installs.rst +++ b/docs/how-to-guides/editable-installs.rst @@ -107,6 +107,11 @@ with different ABIs. An alternative build directory can be specified using the :option:`build-dir` config setting. +If the project needs specific environment variables for Meson, they can +be declared in ``pyproject.toml`` via +:option:`tool.meson-python.env`. Those values are applied both when the +editable wheel is created and when import-time rebuilds are triggered. + Data files ---------- diff --git a/docs/reference/pyproject-settings.rst b/docs/reference/pyproject-settings.rst index 6a7177c10..3bd5ac7b7 100644 --- a/docs/reference/pyproject-settings.rst +++ b/docs/reference/pyproject-settings.rst @@ -47,6 +47,20 @@ use them and examples. ``meson-python`` itself. It can be overridden by the :envvar:`MESON` environment variable. +.. option:: tool.meson-python.env + + A table mapping environment variable names to string values. These + variables are applied automatically to Meson invocations started by + ``meson-python``, including editable rebuilds triggered during import. + + For example: + + .. code-block:: toml + + [tool.meson-python.env] + CC = 'clang' + CFLAGS = '-O0 -g' + .. option:: tool.meson-python.args.dist Extra arguments to be passed to the ``meson dist`` command. diff --git a/mesonpy/__init__.py b/mesonpy/__init__.py index e0328d1a5..ab1441d41 100644 --- a/mesonpy/__init__.py +++ b/mesonpy/__init__.py @@ -531,8 +531,15 @@ def _top_level_modules(self) -> Collection[str]: modules.add(name) return modules - def build(self, directory: Path, source_dir: pathlib.Path, build_dir: pathlib.Path, # type: ignore[override] - build_command: List[str], verbose: bool = False) -> pathlib.Path: + def build( # type: ignore[override] + self, + directory: Path, + source_dir: pathlib.Path, + build_dir: pathlib.Path, + build_command: List[str], + build_env: Dict[str, str], + verbose: bool = False, + ) -> pathlib.Path: wheel_file = pathlib.Path(directory, f'{self.name}.whl') with mesonpy._wheelfile.WheelFile(wheel_file, 'w') as whl: @@ -551,6 +558,7 @@ def build(self, directory: Path, source_dir: pathlib.Path, build_dir: pathlib.Pa {self._top_level_modules!r}, {os.fspath(build_dir)!r}, {build_command!r}, + {build_env!r}, {verbose!r}, )''').encode('utf-8')) @@ -587,6 +595,11 @@ def _bool(value: Any, name: str) -> bool: raise ConfigError(f'Configuration entry "{name}" must be a boolean') return value + def _strings_table(value: Any, name: str) -> Dict[str, str]: + if not isinstance(value, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in value.items()): + raise ConfigError(f'Configuration entry "{name}" must be a table with string values') + return value + def _string_or_path(value: Any, name: str) -> str: if not isinstance(value, str): raise ConfigError(f'Configuration entry "{name}" must be a string') @@ -598,6 +611,7 @@ def _string_or_path(value: Any, name: str) -> str: 'meson': _string_or_path, 'limited-api': _bool, 'allow-windows-internal-shared-libs': _bool, + 'env': _strings_table, 'args': _table(dict.fromkeys(_MESON_ARGS_KEYS, _strings)), 'wheel': _table({ 'exclude': _strings, @@ -682,6 +696,9 @@ def __init__( # load meson args from pyproject.toml pyproject_config = _validate_pyproject_config(pyproject) + self._config_env = pyproject_config.get('env', {}) + self._env = os.environ.copy() + self._env.update(self._config_env) for key, value in pyproject_config.get('args', {}).items(): self._meson_args[key].extend(value) @@ -692,12 +709,12 @@ def __init__( self._meson_args[key].extend(value) # determine command to invoke meson - self._meson = _get_meson_command(pyproject_config.get('meson')) + self._meson = _get_meson_command(pyproject_config.get('meson'), env=self._env) - self._ninja = _env_ninja_command() + self._ninja = _env_ninja_command(env=self._env) if self._ninja is None: raise ConfigError(f'Could not find ninja version {_NINJA_REQUIRED_VERSION} or newer.') - os.environ.setdefault('NINJA', self._ninja) + self._env.setdefault('NINJA', self._ninja) # make sure the build dir exists self._build_dir.mkdir(exist_ok=True, parents=True) @@ -708,7 +725,7 @@ def __init__( # setuptools-like ARCHFLAGS environment variable support if sysconfig.get_platform().startswith('macosx-'): - archflags = os.environ.get('ARCHFLAGS', '').strip() + archflags = self._env.get('ARCHFLAGS', '').strip() if archflags: # parse the ARCHFLAGS environment variable @@ -723,7 +740,7 @@ def __init__( macver, _, nativearch = platform.mac_ver() if arch != nativearch: - x = os.environ.setdefault('_PYTHON_HOST_PLATFORM', f'macosx-{macver}-{arch}') + x = self._env.setdefault('_PYTHON_HOST_PLATFORM', f'macosx-{macver}-{arch}') if not x.endswith(arch): raise ConfigError(f'$ARCHFLAGS={archflags!r} and $_PYTHON_HOST_PLATFORM={x!r} do not agree') family = 'aarch64' if arch == 'arm64' else arch @@ -747,7 +764,7 @@ def __init__( # Simplify cross-compilation for Android with cibuildwheel: detect the # cross-compilation environment set up by cibuildwheel and synthesize an # appropriate cross file. - elif sysconfig.get_platform().startswith('android-') and 'CIBUILDWHEEL' in os.environ: + elif sysconfig.get_platform().startswith('android-') and 'CIBUILDWHEEL' in self._env: cpu = platform.machine() cpu_family = 'x86' if cpu == 'i686' else 'arm' if cpu.startswith('arm') else cpu @@ -893,7 +910,7 @@ def _run(self, cmd: Sequence[str]) -> None: # command line appears before the command output. Without it, # the lines appear in the wrong order in pip output. _log('{style.INFO}+ {cmd}{style.RESET}'.format(style=style, cmd=' '.join(cmd)), flush=True) - r = subprocess.run(cmd, cwd=self._build_dir) + r = subprocess.run(cmd, cwd=self._build_dir, env=self._env) if r.returncode != 0: raise SystemExit(r.returncode) @@ -1135,7 +1152,14 @@ def editable(self, directory: Path) -> pathlib.Path: """Generates an editable wheel in the specified directory.""" self.build() builder = _EditableWheelBuilder(self._metadata, self._manifest, self._limited_api, self._allow_windows_shared_libs) - return builder.build(directory, self._source_dir, self._build_dir, self._build_command, self._editable_verbose) + return builder.build( + directory, + self._source_dir, + self._build_dir, + self._build_command, + self._config_env, + self._editable_verbose, + ) @contextlib.contextmanager @@ -1163,13 +1187,14 @@ def _parse_version_string(string: str) -> Tuple[int, ...]: def _get_meson_command( - meson: Optional[str] = None, *, version: str = _MESON_REQUIRED_VERSION + meson: Optional[str] = None, *, version: str = _MESON_REQUIRED_VERSION, env: Optional[Dict[str, str]] = None ) -> List[str]: """Return the command to invoke meson.""" + env = os.environ if env is None else env # The MESON env var, if set, overrides the config value from pyproject.toml. # The config value, if given, is an absolute path or the name of an executable. - meson = os.environ.get('MESON', meson or 'meson') + meson = env.get('MESON', meson or 'meson') # If the specified Meson string ends in `.py`, we run it with the current # Python executable. This avoids problems for users on Windows, where @@ -1188,7 +1213,7 @@ def _get_meson_command( # but the corresponding meson command is not available in $PATH. Implement # a runtime check to verify that the build environment is setup correcly. try: - r = subprocess.run(cmd + ['--version'], text=True, capture_output=True) + r = subprocess.run(cmd + ['--version'], text=True, capture_output=True, env=env) except FileNotFoundError as err: raise ConfigError(f'meson executable "{meson}" not found') from err if r.returncode != 0: @@ -1201,15 +1226,16 @@ def _get_meson_command( return cmd -def _env_ninja_command(*, version: str = _NINJA_REQUIRED_VERSION) -> Optional[str]: +def _env_ninja_command(*, version: str = _NINJA_REQUIRED_VERSION, env: Optional[Dict[str, str]] = None) -> Optional[str]: """Returns the path to ninja, or None if no ninja found.""" required_version = _parse_version_string(version) - env_ninja = os.environ.get('NINJA') + env = os.environ if env is None else env + env_ninja = env.get('NINJA') ninja_candidates = [env_ninja] if env_ninja else ['ninja', 'ninja-build', 'samu'] for ninja in ninja_candidates: ninja_path = shutil.which(ninja) if ninja_path is not None: - version = subprocess.run([ninja_path, '--version'], check=False, text=True, capture_output=True).stdout + version = subprocess.run([ninja_path, '--version'], check=False, text=True, capture_output=True, env=env).stdout if _parse_version_string(version) >= required_version: return ninja_path return None diff --git a/mesonpy/_editable.py b/mesonpy/_editable.py index 16469b8ca..833221ef8 100644 --- a/mesonpy/_editable.py +++ b/mesonpy/_editable.py @@ -288,11 +288,20 @@ def find_spec(fullname: str, tree: Node) -> Optional[importlib.machinery.ModuleS class MesonpyMetaFinder(importlib.abc.MetaPathFinder): - def __init__(self, package: str, names: Set[str], path: str, cmd: List[str], verbose: bool = False): + def __init__( + self, + package: str, + names: Set[str], + path: str, + cmd: List[str], + env: Optional[Dict[str, str]] = None, + verbose: bool = False, + ): self._name = package self._top_level_modules = names self._build_path = path self._build_cmd = cmd + self._build_env = env or {} self._verbose = verbose self._loaders: List[Tuple[type, str]] = [] @@ -334,6 +343,7 @@ def _rebuild(self) -> Node: # the module we are rebuilding might be imported causing a # rebuild loop. env = os.environ.copy() + env.update(self._build_env) env[MARKER] = os.pathsep.join((env.get(MARKER, ''), self._build_path)) if self._verbose or bool(env.get(VERBOSE, '')): @@ -389,7 +399,14 @@ def iter_modules(self, prefix: str) -> Iterator[Tuple[str, bool]]: yield prefix + modname, False -def install(package: str, names: Set[str], path: str, cmd: List[str], verbose: bool) -> None: - finder = MesonpyMetaFinder(package, names, path, cmd, verbose) +def install( + package: str, + names: Set[str], + path: str, + cmd: List[str], + env: Dict[str, str], + verbose: bool, +) -> None: + finder = MesonpyMetaFinder(package, names, path, cmd, env, verbose) sys.meta_path.insert(0, finder) sys.path_hooks.insert(0, finder._path_hook) diff --git a/tests/test_editable.py b/tests/test_editable.py index 92656ec63..a905af4fa 100644 --- a/tests/test_editable.py +++ b/tests/test_editable.py @@ -9,6 +9,7 @@ import re import subprocess import sys +import builtins from contextlib import redirect_stdout @@ -82,7 +83,7 @@ def test_mesonpy_meta_finder(package_complex, tmp_path): project = mesonpy.Project(package_complex, tmp_path) # point the meta finder to the build directory - finder = _editable.MesonpyMetaFinder('complex', {'complex'}, os.fspath(tmp_path), project._build_command, True) + finder = _editable.MesonpyMetaFinder('complex', {'complex'}, os.fspath(tmp_path), project._build_command, verbose=True) # check repr assert repr(finder) == f'MesonpyMetaFinder(\'complex\', {str(tmp_path)!r})' @@ -146,7 +147,7 @@ def test_resources(tmp_path): project = mesonpy.Project(package_path, tmp_path) # point the meta finder to the build directory - finder = _editable.MesonpyMetaFinder('simple', {'simple'}, os.fspath(tmp_path), project._build_command, True) + finder = _editable.MesonpyMetaFinder('simple', {'simple'}, os.fspath(tmp_path), project._build_command, verbose=True) # verify that we can look up resources spec = finder.find_spec('simple') @@ -165,7 +166,7 @@ def test_importlib_resources(tmp_path): project = mesonpy.Project(package_path, tmp_path) # point the meta finder to the build directory - finder = _editable.MesonpyMetaFinder('simple', {'simple'}, os.fspath(tmp_path), project._build_command, True) + finder = _editable.MesonpyMetaFinder('simple', {'simple'}, os.fspath(tmp_path), project._build_command, verbose=True) try: # install the finder in the meta path @@ -216,7 +217,7 @@ def test_editable_pkgutils_walk_packages(package_complex, tmp_path): # build a package in a temporary directory project = mesonpy.Project(package_complex, tmp_path) - finder = _editable.MesonpyMetaFinder('complex', {'complex'}, os.fspath(tmp_path), project._build_command, True) + finder = _editable.MesonpyMetaFinder('complex', {'complex'}, os.fspath(tmp_path), project._build_command, verbose=True) try: # install editable hooks @@ -249,7 +250,7 @@ def test_editable_pkgutils_walk_packages(package_complex, tmp_path): def test_custom_target_install_dir(package_custom_target_dir, tmp_path): project = mesonpy.Project(package_custom_target_dir, tmp_path) - finder = _editable.MesonpyMetaFinder('package', {'package'}, os.fspath(tmp_path), project._build_command, True) + finder = _editable.MesonpyMetaFinder('package', {'package'}, os.fspath(tmp_path), project._build_command, verbose=True) try: sys.meta_path.insert(0, finder) import package.generated.one @@ -314,6 +315,32 @@ def test_editable_verbose(venv, package_complex, editable_complex, monkeypatch): assert venv.python('-c', 'import complex') == '' +def test_editable_rebuild_uses_configured_env(monkeypatch): + finder = _editable.MesonpyMetaFinder( + 'test', + {'test'}, + os.getcwd(), + ['ninja'], + {'MESONPY_TEST_ENV': 'inner'}, + ) + captured = [] + + def work_to_do(env): + captured.append(env['MESONPY_TEST_ENV']) + return False + + def open_mock(*args, **kwargs): + return io.StringIO('{}') + + monkeypatch.setenv('MESONPY_TEST_ENV', 'outer') + monkeypatch.setattr(finder, '_work_to_do', work_to_do) + monkeypatch.setattr(builtins, 'open', open_mock) + finder._verbose = True + finder._rebuild() + + assert captured == ['inner'] + + @pytest.mark.parametrize('verbose', [False, True], ids=('', 'verbose')) def test_editable_rebuild_error(package_purelib_and_platlib, tmp_path, verbose): with mesonpy._project({'builddir': os.fspath(tmp_path)}) as project: diff --git a/tests/test_project.py b/tests/test_project.py index 76267964f..03a66c670 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -263,6 +263,9 @@ def test_validate_pyproject_config_one(): def test_validate_pyproject_config_all(): pyproject_config = tomllib.loads(textwrap.dedent(''' + [tool.meson-python.env] + FOO = 'bar' + [tool.meson-python.args] setup = ['-Dfoo=true'] dist = [] @@ -270,6 +273,7 @@ def test_validate_pyproject_config_all(): install = ['--tags=python'] ''')) conf = mesonpy._validate_pyproject_config(pyproject_config) + assert conf['env'] == {'FOO': 'bar'} assert conf['args'] == { 'setup': ['-Dfoo=true'], 'dist': [], @@ -277,6 +281,15 @@ def test_validate_pyproject_config_all(): 'install': ['--tags=python']} +def test_validate_pyproject_config_env_invalid(): + pyproject_config = tomllib.loads(textwrap.dedent(''' + [tool.meson-python.env] + FOO = 1 + ''')) + with pytest.raises(mesonpy.ConfigError, match='Configuration entry "tool.meson-python.env" must be a table with string values'): + mesonpy._validate_pyproject_config(pyproject_config) + + def test_validate_pyproject_config_unknown(): pyproject_config = tomllib.loads(textwrap.dedent(''' [tool.meson-python.args] @@ -292,6 +305,43 @@ def test_validate_pyproject_config_empty(): assert config == {} +def test_project_env_vars(tmp_path, monkeypatch): + captured_envs = [] + subprocess_run = mesonpy.subprocess.run + + def run(cmd, **kwargs): + env = kwargs.get('env') + assert env is not None, f'Expected environment variables to be passed to subprocess.run {cmd}' + assert 'MESONPY_TEST_ENV' in env and env['MESONPY_TEST_ENV'] == 'inner', f'Expected MESONPY_TEST_ENV to be set in the environment for subprocess.run {cmd}' + captured_envs.append(env) + return subprocess_run(cmd, **kwargs) + + monkeypatch.setattr(mesonpy.subprocess, 'run', run) + monkeypatch.setenv('MESONPY_TEST_ENV', 'outer') + source_dir = tmp_path / 'src' + build_dir = tmp_path / 'build' + source_dir.mkdir() + + source_dir.joinpath('pyproject.toml').write_text(textwrap.dedent(''' + [build-system] + build-backend = 'mesonpy' + requires = ['meson-python'] + + [project] + name = 'pure' + version = '1.0.0' + + [tool.meson-python.env] + MESONPY_TEST_ENV = 'inner' + '''), encoding='utf-8') + source_dir.joinpath('meson.build').write_text("project('pure', version: '1.0.0')\n", encoding='utf-8') + + project = mesonpy.Project(source_dir, build_dir) + project.build() + + assert captured_envs + + @pytest.mark.skipif( sys.version_info < (3, 8), reason="unittest.mock doesn't support the required APIs for this test",