Skip to content

Commit 19a90de

Browse files
tobiasdiezCopilot
andcommitted
Added support for tool.meson-python.env table in pyproject.toml
Co-authored-by: Copilot <copilot@github.com>
1 parent e5ba90f commit 19a90de

6 files changed

Lines changed: 163 additions & 24 deletions

File tree

docs/how-to-guides/editable-installs.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,11 @@ with different ABIs.
107107
An alternative build directory can be specified using the
108108
:option:`build-dir` config setting.
109109

110+
If the project needs specific environment variables for Meson, they can
111+
be declared in ``pyproject.toml`` via
112+
:option:`tool.meson-python.env`. Those values are applied both when the
113+
editable wheel is created and when import-time rebuilds are triggered.
114+
110115

111116
Data files
112117
----------

docs/reference/pyproject-settings.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,20 @@ use them and examples.
4747
``meson-python`` itself. It can be overridden by the :envvar:`MESON`
4848
environment variable.
4949

50+
.. option:: tool.meson-python.env
51+
52+
A table mapping environment variable names to string values. These
53+
variables are applied automatically to Meson invocations started by
54+
``meson-python``, including editable rebuilds triggered during import.
55+
56+
For example:
57+
58+
.. code-block:: toml
59+
60+
[tool.meson-python.env]
61+
CC = 'clang'
62+
CFLAGS = '-O0 -g'
63+
5064
.. option:: tool.meson-python.args.dist
5165

5266
Extra arguments to be passed to the ``meson dist`` command.

mesonpy/__init__.py

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -531,8 +531,15 @@ def _top_level_modules(self) -> Collection[str]:
531531
modules.add(name)
532532
return modules
533533

534-
def build(self, directory: Path, source_dir: pathlib.Path, build_dir: pathlib.Path, # type: ignore[override]
535-
build_command: List[str], verbose: bool = False) -> pathlib.Path:
534+
def build( # type: ignore[override]
535+
self,
536+
directory: Path,
537+
source_dir: pathlib.Path,
538+
build_dir: pathlib.Path,
539+
build_command: List[str],
540+
build_env: Dict[str, str],
541+
verbose: bool = False,
542+
) -> pathlib.Path:
536543

537544
wheel_file = pathlib.Path(directory, f'{self.name}.whl')
538545
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
551558
{self._top_level_modules!r},
552559
{os.fspath(build_dir)!r},
553560
{build_command!r},
561+
{build_env!r},
554562
{verbose!r},
555563
)''').encode('utf-8'))
556564

@@ -587,6 +595,11 @@ def _bool(value: Any, name: str) -> bool:
587595
raise ConfigError(f'Configuration entry "{name}" must be a boolean')
588596
return value
589597

598+
def _strings_table(value: Any, name: str) -> Dict[str, str]:
599+
if not isinstance(value, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in value.items()):
600+
raise ConfigError(f'Configuration entry "{name}" must be a table with string values')
601+
return value
602+
590603
def _string_or_path(value: Any, name: str) -> str:
591604
if not isinstance(value, str):
592605
raise ConfigError(f'Configuration entry "{name}" must be a string')
@@ -598,6 +611,7 @@ def _string_or_path(value: Any, name: str) -> str:
598611
'meson': _string_or_path,
599612
'limited-api': _bool,
600613
'allow-windows-internal-shared-libs': _bool,
614+
'env': _strings_table,
601615
'args': _table(dict.fromkeys(_MESON_ARGS_KEYS, _strings)),
602616
'wheel': _table({
603617
'exclude': _strings,
@@ -682,6 +696,9 @@ def __init__(
682696

683697
# load meson args from pyproject.toml
684698
pyproject_config = _validate_pyproject_config(pyproject)
699+
self._config_env = pyproject_config.get('env', {})
700+
self._env = os.environ.copy()
701+
self._env.update(self._config_env)
685702
for key, value in pyproject_config.get('args', {}).items():
686703
self._meson_args[key].extend(value)
687704

@@ -692,12 +709,12 @@ def __init__(
692709
self._meson_args[key].extend(value)
693710

694711
# determine command to invoke meson
695-
self._meson = _get_meson_command(pyproject_config.get('meson'))
712+
self._meson = _get_meson_command(pyproject_config.get('meson'), env=self._env)
696713

697-
self._ninja = _env_ninja_command()
714+
self._ninja = _env_ninja_command(env=self._env)
698715
if self._ninja is None:
699716
raise ConfigError(f'Could not find ninja version {_NINJA_REQUIRED_VERSION} or newer.')
700-
os.environ.setdefault('NINJA', self._ninja)
717+
self._env.setdefault('NINJA', self._ninja)
701718

702719
# make sure the build dir exists
703720
self._build_dir.mkdir(exist_ok=True, parents=True)
@@ -708,7 +725,7 @@ def __init__(
708725

709726
# setuptools-like ARCHFLAGS environment variable support
710727
if sysconfig.get_platform().startswith('macosx-'):
711-
archflags = os.environ.get('ARCHFLAGS', '').strip()
728+
archflags = self._env.get('ARCHFLAGS', '').strip()
712729
if archflags:
713730

714731
# parse the ARCHFLAGS environment variable
@@ -723,7 +740,7 @@ def __init__(
723740

724741
macver, _, nativearch = platform.mac_ver()
725742
if arch != nativearch:
726-
x = os.environ.setdefault('_PYTHON_HOST_PLATFORM', f'macosx-{macver}-{arch}')
743+
x = self._env.setdefault('_PYTHON_HOST_PLATFORM', f'macosx-{macver}-{arch}')
727744
if not x.endswith(arch):
728745
raise ConfigError(f'$ARCHFLAGS={archflags!r} and $_PYTHON_HOST_PLATFORM={x!r} do not agree')
729746
family = 'aarch64' if arch == 'arm64' else arch
@@ -747,7 +764,7 @@ def __init__(
747764
# Simplify cross-compilation for Android with cibuildwheel: detect the
748765
# cross-compilation environment set up by cibuildwheel and synthesize an
749766
# appropriate cross file.
750-
elif sysconfig.get_platform().startswith('android-') and 'CIBUILDWHEEL' in os.environ:
767+
elif sysconfig.get_platform().startswith('android-') and 'CIBUILDWHEEL' in self._env:
751768
cpu = platform.machine()
752769
cpu_family = 'x86' if cpu == 'i686' else 'arm' if cpu.startswith('arm') else cpu
753770

@@ -893,7 +910,7 @@ def _run(self, cmd: Sequence[str]) -> None:
893910
# command line appears before the command output. Without it,
894911
# the lines appear in the wrong order in pip output.
895912
_log('{style.INFO}+ {cmd}{style.RESET}'.format(style=style, cmd=' '.join(cmd)), flush=True)
896-
r = subprocess.run(cmd, cwd=self._build_dir)
913+
r = subprocess.run(cmd, cwd=self._build_dir, env=self._env)
897914
if r.returncode != 0:
898915
raise SystemExit(r.returncode)
899916

@@ -1135,7 +1152,14 @@ def editable(self, directory: Path) -> pathlib.Path:
11351152
"""Generates an editable wheel in the specified directory."""
11361153
self.build()
11371154
builder = _EditableWheelBuilder(self._metadata, self._manifest, self._limited_api, self._allow_windows_shared_libs)
1138-
return builder.build(directory, self._source_dir, self._build_dir, self._build_command, self._editable_verbose)
1155+
return builder.build(
1156+
directory,
1157+
self._source_dir,
1158+
self._build_dir,
1159+
self._build_command,
1160+
self._config_env,
1161+
self._editable_verbose,
1162+
)
11391163

11401164

11411165
@contextlib.contextmanager
@@ -1163,13 +1187,14 @@ def _parse_version_string(string: str) -> Tuple[int, ...]:
11631187

11641188

11651189
def _get_meson_command(
1166-
meson: Optional[str] = None, *, version: str = _MESON_REQUIRED_VERSION
1190+
meson: Optional[str] = None, *, version: str = _MESON_REQUIRED_VERSION, env: Optional[Dict[str, str]] = None
11671191
) -> List[str]:
11681192
"""Return the command to invoke meson."""
1193+
env = os.environ if env is None else env
11691194

11701195
# The MESON env var, if set, overrides the config value from pyproject.toml.
11711196
# The config value, if given, is an absolute path or the name of an executable.
1172-
meson = os.environ.get('MESON', meson or 'meson')
1197+
meson = env.get('MESON', meson or 'meson')
11731198

11741199
# If the specified Meson string ends in `.py`, we run it with the current
11751200
# Python executable. This avoids problems for users on Windows, where
@@ -1188,7 +1213,7 @@ def _get_meson_command(
11881213
# but the corresponding meson command is not available in $PATH. Implement
11891214
# a runtime check to verify that the build environment is setup correcly.
11901215
try:
1191-
r = subprocess.run(cmd + ['--version'], text=True, capture_output=True)
1216+
r = subprocess.run(cmd + ['--version'], text=True, capture_output=True, env=env)
11921217
except FileNotFoundError as err:
11931218
raise ConfigError(f'meson executable "{meson}" not found') from err
11941219
if r.returncode != 0:
@@ -1201,15 +1226,16 @@ def _get_meson_command(
12011226
return cmd
12021227

12031228

1204-
def _env_ninja_command(*, version: str = _NINJA_REQUIRED_VERSION) -> Optional[str]:
1229+
def _env_ninja_command(*, version: str = _NINJA_REQUIRED_VERSION, env: Optional[Dict[str, str]] = None) -> Optional[str]:
12051230
"""Returns the path to ninja, or None if no ninja found."""
12061231
required_version = _parse_version_string(version)
1207-
env_ninja = os.environ.get('NINJA')
1232+
env = os.environ if env is None else env
1233+
env_ninja = env.get('NINJA')
12081234
ninja_candidates = [env_ninja] if env_ninja else ['ninja', 'ninja-build', 'samu']
12091235
for ninja in ninja_candidates:
12101236
ninja_path = shutil.which(ninja)
12111237
if ninja_path is not None:
1212-
version = subprocess.run([ninja_path, '--version'], check=False, text=True, capture_output=True).stdout
1238+
version = subprocess.run([ninja_path, '--version'], check=False, text=True, capture_output=True, env=env).stdout
12131239
if _parse_version_string(version) >= required_version:
12141240
return ninja_path
12151241
return None

mesonpy/_editable.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -288,11 +288,20 @@ def find_spec(fullname: str, tree: Node) -> Optional[importlib.machinery.ModuleS
288288

289289

290290
class MesonpyMetaFinder(importlib.abc.MetaPathFinder):
291-
def __init__(self, package: str, names: Set[str], path: str, cmd: List[str], verbose: bool = False):
291+
def __init__(
292+
self,
293+
package: str,
294+
names: Set[str],
295+
path: str,
296+
cmd: List[str],
297+
env: Optional[Dict[str, str]] = None,
298+
verbose: bool = False,
299+
):
292300
self._name = package
293301
self._top_level_modules = names
294302
self._build_path = path
295303
self._build_cmd = cmd
304+
self._build_env = env or {}
296305
self._verbose = verbose
297306
self._loaders: List[Tuple[type, str]] = []
298307

@@ -334,6 +343,7 @@ def _rebuild(self) -> Node:
334343
# the module we are rebuilding might be imported causing a
335344
# rebuild loop.
336345
env = os.environ.copy()
346+
env.update(self._build_env)
337347
env[MARKER] = os.pathsep.join((env.get(MARKER, ''), self._build_path))
338348

339349
if self._verbose or bool(env.get(VERBOSE, '')):
@@ -389,7 +399,14 @@ def iter_modules(self, prefix: str) -> Iterator[Tuple[str, bool]]:
389399
yield prefix + modname, False
390400

391401

392-
def install(package: str, names: Set[str], path: str, cmd: List[str], verbose: bool) -> None:
393-
finder = MesonpyMetaFinder(package, names, path, cmd, verbose)
402+
def install(
403+
package: str,
404+
names: Set[str],
405+
path: str,
406+
cmd: List[str],
407+
env: Dict[str, str],
408+
verbose: bool,
409+
) -> None:
410+
finder = MesonpyMetaFinder(package, names, path, cmd, env, verbose)
394411
sys.meta_path.insert(0, finder)
395412
sys.path_hooks.insert(0, finder._path_hook)

tests/test_editable.py

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import re
1010
import subprocess
1111
import sys
12+
import builtins
1213

1314
from contextlib import redirect_stdout
1415

@@ -82,7 +83,7 @@ def test_mesonpy_meta_finder(package_complex, tmp_path):
8283
project = mesonpy.Project(package_complex, tmp_path)
8384

8485
# point the meta finder to the build directory
85-
finder = _editable.MesonpyMetaFinder('complex', {'complex'}, os.fspath(tmp_path), project._build_command, True)
86+
finder = _editable.MesonpyMetaFinder('complex', {'complex'}, os.fspath(tmp_path), project._build_command, verbose=True)
8687

8788
# check repr
8889
assert repr(finder) == f'MesonpyMetaFinder(\'complex\', {str(tmp_path)!r})'
@@ -146,7 +147,7 @@ def test_resources(tmp_path):
146147
project = mesonpy.Project(package_path, tmp_path)
147148

148149
# point the meta finder to the build directory
149-
finder = _editable.MesonpyMetaFinder('simple', {'simple'}, os.fspath(tmp_path), project._build_command, True)
150+
finder = _editable.MesonpyMetaFinder('simple', {'simple'}, os.fspath(tmp_path), project._build_command, verbose=True)
150151

151152
# verify that we can look up resources
152153
spec = finder.find_spec('simple')
@@ -165,7 +166,7 @@ def test_importlib_resources(tmp_path):
165166
project = mesonpy.Project(package_path, tmp_path)
166167

167168
# point the meta finder to the build directory
168-
finder = _editable.MesonpyMetaFinder('simple', {'simple'}, os.fspath(tmp_path), project._build_command, True)
169+
finder = _editable.MesonpyMetaFinder('simple', {'simple'}, os.fspath(tmp_path), project._build_command, verbose=True)
169170

170171
try:
171172
# install the finder in the meta path
@@ -216,7 +217,7 @@ def test_editable_pkgutils_walk_packages(package_complex, tmp_path):
216217
# build a package in a temporary directory
217218
project = mesonpy.Project(package_complex, tmp_path)
218219

219-
finder = _editable.MesonpyMetaFinder('complex', {'complex'}, os.fspath(tmp_path), project._build_command, True)
220+
finder = _editable.MesonpyMetaFinder('complex', {'complex'}, os.fspath(tmp_path), project._build_command, verbose=True)
220221

221222
try:
222223
# install editable hooks
@@ -249,7 +250,7 @@ def test_editable_pkgutils_walk_packages(package_complex, tmp_path):
249250

250251
def test_custom_target_install_dir(package_custom_target_dir, tmp_path):
251252
project = mesonpy.Project(package_custom_target_dir, tmp_path)
252-
finder = _editable.MesonpyMetaFinder('package', {'package'}, os.fspath(tmp_path), project._build_command, True)
253+
finder = _editable.MesonpyMetaFinder('package', {'package'}, os.fspath(tmp_path), project._build_command, verbose=True)
253254
try:
254255
sys.meta_path.insert(0, finder)
255256
import package.generated.one
@@ -314,6 +315,32 @@ def test_editable_verbose(venv, package_complex, editable_complex, monkeypatch):
314315
assert venv.python('-c', 'import complex') == ''
315316

316317

318+
def test_editable_rebuild_uses_configured_env(monkeypatch):
319+
finder = _editable.MesonpyMetaFinder(
320+
'test',
321+
{'test'},
322+
os.getcwd(),
323+
['ninja'],
324+
{'MESONPY_TEST_ENV': 'inner'},
325+
)
326+
captured = []
327+
328+
def work_to_do(env):
329+
captured.append(env['MESONPY_TEST_ENV'])
330+
return False
331+
332+
def open_mock(*args, **kwargs):
333+
return io.StringIO('{}')
334+
335+
monkeypatch.setenv('MESONPY_TEST_ENV', 'outer')
336+
monkeypatch.setattr(finder, '_work_to_do', work_to_do)
337+
monkeypatch.setattr(builtins, 'open', open_mock)
338+
finder._verbose = True
339+
finder._rebuild()
340+
341+
assert captured == ['inner']
342+
343+
317344
@pytest.mark.parametrize('verbose', [False, True], ids=('', 'verbose'))
318345
def test_editable_rebuild_error(package_purelib_and_platlib, tmp_path, verbose):
319346
with mesonpy._project({'builddir': os.fspath(tmp_path)}) as project:

0 commit comments

Comments
 (0)