Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/how-to-guides/editable-installs.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------
Expand Down
14 changes: 14 additions & 0 deletions docs/reference/pyproject-settings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
58 changes: 42 additions & 16 deletions mesonpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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'))

Expand Down Expand Up @@ -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')
Expand All @@ -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,
Expand Down Expand Up @@ -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)

Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand Down
23 changes: 20 additions & 3 deletions mesonpy/_editable.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]] = []

Expand Down Expand Up @@ -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, '')):
Expand Down Expand Up @@ -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)
37 changes: 32 additions & 5 deletions tests/test_editable.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import re
import subprocess
import sys
import builtins

from contextlib import redirect_stdout

Expand Down Expand Up @@ -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})'
Expand Down Expand Up @@ -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')
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading