From 5fcf9e1ec5a6a2b99ea89597ee3d22b7cbba9c5c Mon Sep 17 00:00:00 2001 From: Daniele Nicolodi Date: Wed, 6 Sep 2023 16:10:41 +0200 Subject: [PATCH] ENH: implement variables substitution in configuration --- meson.build | 1 + mesonpy/__init__.py | 7 ++ mesonpy/_substitutions.py | 96 +++++++++++++++++++ .../substitutions-invalid/meson.build | 5 + .../substitutions-invalid/pyproject.toml | 10 ++ tests/packages/substitutions/meson.build | 5 + tests/packages/substitutions/pyproject.toml | 10 ++ tests/test_substitutions.py | 34 +++++++ 8 files changed, 168 insertions(+) create mode 100644 mesonpy/_substitutions.py create mode 100644 tests/packages/substitutions-invalid/meson.build create mode 100644 tests/packages/substitutions-invalid/pyproject.toml create mode 100644 tests/packages/substitutions/meson.build create mode 100644 tests/packages/substitutions/pyproject.toml create mode 100644 tests/test_substitutions.py diff --git a/meson.build b/meson.build index 5b4f434ad..83582f4ba 100644 --- a/meson.build +++ b/meson.build @@ -11,6 +11,7 @@ py.install_sources( 'mesonpy/_compat.py', 'mesonpy/_editable.py', 'mesonpy/_rpath.py', + 'mesonpy/_substitutions.py', 'mesonpy/_tags.py', 'mesonpy/_util.py', 'mesonpy/_wheelfile.py', diff --git a/mesonpy/__init__.py b/mesonpy/__init__.py index 541256f4c..2aeb01db2 100644 --- a/mesonpy/__init__.py +++ b/mesonpy/__init__.py @@ -47,6 +47,7 @@ import mesonpy._compat import mesonpy._rpath +import mesonpy._substitutions import mesonpy._tags import mesonpy._util import mesonpy._wheelfile @@ -660,6 +661,12 @@ def __init__( # noqa: C901 # load meson args from pyproject.toml pyproject_config = _validate_pyproject_config(pyproject) for key, value in pyproject_config.get('args', {}).items(): + # apply variable interpolation + try: + value = [mesonpy._substitutions.interpolate(x) for x in value] + except ValueError as exc: + raise ConfigError( + f'Cannot interpret value for "tool.meson-python.args.{key}" configuration entry: {exc.args[0]}') from None self._meson_args[key].extend(value) # meson arguments from the command line take precedence over diff --git a/mesonpy/_substitutions.py b/mesonpy/_substitutions.py new file mode 100644 index 000000000..bc87b0404 --- /dev/null +++ b/mesonpy/_substitutions.py @@ -0,0 +1,96 @@ +# SPDX-FileCopyrightText: 2023 The meson-python developers +# +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +import ast +import operator +import typing + + +if typing.TYPE_CHECKING: # pragma: no cover + from typing import Any, Callable, Mapping, Optional, Type + + +_methods = {} + + +def _register(nodetype: Type[ast.AST]) -> Callable[..., Callable[..., Any]]: + def closure(method: Callable[[Interpreter, ast.AST], Any]) -> Callable[[Interpreter, ast.AST], Any]: + _methods[nodetype] = method + return method + return closure + + +class Interpreter: + + _operators = { + ast.Add: operator.add, + ast.Sub: operator.sub, + ast.Mult: operator.mul, + ast.Div: operator.truediv, + } + + def __init__(self, variables: Mapping[str, Any]): + self._variables = variables + + def eval(self, string: str) -> Any: + try: + expr = ast.parse(string, mode='eval') + return self._eval(expr) + except KeyError as exc: + raise ValueError(f'unknown variable "{exc.args[0]}"') from exc + except NotImplementedError as exc: + raise ValueError(f'invalid expression {string!r}') from exc + + __getitem__ = eval + + def _eval(self, node: ast.AST) -> Any: + # Cannot use functools.singlemethoddispatch as long as Python 3.7 is supported. + method = _methods.get(type(node), None) + if method is None: + raise NotImplementedError + return method(self, node) + + @_register(ast.Expression) + def _expression(self, node: ast.Expression) -> Any: + return self._eval(node.body) + + @_register(ast.BinOp) + def _binop(self, node: ast.BinOp) -> Any: + func = self._operators.get(type(node.op)) + if func is None: + raise NotImplementedError + return func(self._eval(node.left), self._eval(node.right)) + + @_register(ast.Constant) + def _constant(self, node: ast.Constant) -> Any: + return node.value + + # Python 3.7, replaced by ast.Constant is later versions. + @_register(ast.Num) + def _num(self, node: ast.Num) -> Any: + return node.n + + # Python 3.7, replaced by ast.Constant is later versions. + @_register(ast.Str) + def _str(self, node: ast.Str) -> Any: + return node.s + + @_register(ast.Name) + def _variable(self, node: ast.Name) -> Any: + value = self._variables[node.id] + if callable(value): + value = value() + return value + + +def _ncores() -> int: + return 42 + + +def interpolate(string: str, variables: Optional[Mapping[str, Any]] = None) -> str: + if variables is None: + variables = {'ncores': _ncores} + return string % Interpreter(variables) diff --git a/tests/packages/substitutions-invalid/meson.build b/tests/packages/substitutions-invalid/meson.build new file mode 100644 index 000000000..6dc45dc87 --- /dev/null +++ b/tests/packages/substitutions-invalid/meson.build @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: 2023 The meson-python developers +# +# SPDX-License-Identifier: MIT + +project('substitutions', version: '0.0.1') diff --git a/tests/packages/substitutions-invalid/pyproject.toml b/tests/packages/substitutions-invalid/pyproject.toml new file mode 100644 index 000000000..8a2702c24 --- /dev/null +++ b/tests/packages/substitutions-invalid/pyproject.toml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2023 The meson-python developers +# +# SPDX-License-Identifier: MIT + +[build-system] +build-backend = 'mesonpy' +requires = ['meson-python'] + +[tool.meson-python.args] +compile = ['-j', '%(xxx)d'] diff --git a/tests/packages/substitutions/meson.build b/tests/packages/substitutions/meson.build new file mode 100644 index 000000000..6dc45dc87 --- /dev/null +++ b/tests/packages/substitutions/meson.build @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: 2023 The meson-python developers +# +# SPDX-License-Identifier: MIT + +project('substitutions', version: '0.0.1') diff --git a/tests/packages/substitutions/pyproject.toml b/tests/packages/substitutions/pyproject.toml new file mode 100644 index 000000000..13c7485ab --- /dev/null +++ b/tests/packages/substitutions/pyproject.toml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2023 The meson-python developers +# +# SPDX-License-Identifier: MIT + +[build-system] +build-backend = 'mesonpy' +requires = ['meson-python'] + +[tool.meson-python.args] +compile = ['-j', '%(ncores / 2 + 2)d'] diff --git a/tests/test_substitutions.py b/tests/test_substitutions.py new file mode 100644 index 000000000..71b6e60a5 --- /dev/null +++ b/tests/test_substitutions.py @@ -0,0 +1,34 @@ +# SPDX-FileCopyrightText: 2023 The meson-python developers +# +# SPDX-License-Identifier: MIT + +import pytest + +import mesonpy + + +def test_interpolate(): + assert mesonpy._substitutions.interpolate('%(x * 2 + 3 - 4 / 1)d', {'x': 1}) == '1' + + +def test_interpolate_key_error(): + with pytest.raises(RuntimeError, match='unknown variable "y"'): + mesonpy._substitutions.interpolate('%(y)d', {'x': 1}) + + +def test_interpolate_not_implemented(): + with pytest.raises(RuntimeError, match='invalid expression'): + mesonpy._substitutions.interpolate('%(x ** 2)d', {'x': 1}) + + +def test_substitutions(package_substitutions, monkeypatch): + monkeypatch.setattr(mesonpy._substitutions, '_ncores', lambda: 2) + with mesonpy._project() as project: + assert project._meson_args['compile'] == ['-j', '3'] + + +def test_substitutions_invalid(package_substitutions_invalid, monkeypatch): + monkeypatch.setattr(mesonpy._substitutions, '_ncores', lambda: 2) + with pytest.raises(mesonpy.ConfigError, match=''): + with mesonpy._project(): + pass