Skip to content

Commit 3e41feb

Browse files
committed
ENH: implement variables substitution in configuration
1 parent 36e8205 commit 3e41feb

File tree

8 files changed

+186
-1
lines changed

8 files changed

+186
-1
lines changed

meson.build

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ py.install_sources(
1111
'mesonpy/_compat.py',
1212
'mesonpy/_editable.py',
1313
'mesonpy/_rpath.py',
14+
'mesonpy/_substitutions.py',
1415
'mesonpy/_tags.py',
1516
'mesonpy/_util.py',
1617
'mesonpy/_wheelfile.py',

mesonpy/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545

4646
import mesonpy._compat
4747
import mesonpy._rpath
48+
import mesonpy._substitutions
4849
import mesonpy._tags
4950
import mesonpy._util
5051
import mesonpy._wheelfile
@@ -606,7 +607,11 @@ def __init__(
606607
# load meson args from pyproject.toml
607608
pyproject_config = _validate_pyproject_config(pyproject)
608609
for key, value in pyproject_config.get('args', {}).items():
609-
self._meson_args[key].extend(value)
610+
try:
611+
self._meson_args[key] = [mesonpy._substitutions.eval(x) for x in value]
612+
except ValueError as exc:
613+
raise ConfigError(
614+
f'Cannot evaluate "tool.meson-python.args.{key}" configuration entry: {exc.args[0]}') from None
610615

611616
# meson arguments from the command line take precedence over
612617
# arguments from the configuration file thus are added later

mesonpy/_substitutions.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# SPDX-FileCopyrightText: 2023 The meson-python developers
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
from __future__ import annotations
6+
7+
import ast
8+
import operator
9+
import string
10+
import sys
11+
import typing
12+
13+
14+
if typing.TYPE_CHECKING: # pragma: no cover
15+
from typing import Any, Callable, Iterator, Mapping, Optional, Type
16+
17+
18+
_methods = {}
19+
20+
21+
def _register(nodetype: Type[ast.AST]) -> Callable[..., Callable[..., Any]]:
22+
def closure(method: Callable[[Interpreter, ast.AST], Any]) -> Callable[[Interpreter, ast.AST], Any]:
23+
_methods[nodetype] = method
24+
return method
25+
return closure
26+
27+
28+
class Interpreter(typing.Mapping[str, object]):
29+
30+
_operators = {
31+
ast.Add: operator.add,
32+
ast.Sub: operator.sub,
33+
ast.Mult: operator.mul,
34+
ast.Div: operator.truediv,
35+
ast.FloorDiv: operator.floordiv,
36+
}
37+
38+
def __init__(self, variables: Mapping[str, Any]):
39+
self._variables = variables
40+
41+
def eval(self, string: str) -> Any:
42+
try:
43+
expr = ast.parse(string, mode='eval')
44+
return self._eval(expr)
45+
except KeyError as exc:
46+
raise ValueError(f'unknown variable "{exc.args[0]}"') from exc
47+
except NotImplementedError as exc:
48+
raise ValueError(f'invalid expression {string!r}') from exc
49+
50+
__getitem__ = eval
51+
52+
def __len__(self) -> int:
53+
return len(self._variables)
54+
55+
def __iter__(self) -> Iterator[str]:
56+
return iter(self._variables)
57+
58+
def _eval(self, node: ast.AST) -> Any:
59+
# Cannot use functools.singlemethoddispatch as long as Python 3.7 is supported.
60+
method = _methods.get(type(node), None)
61+
if method is None:
62+
raise NotImplementedError
63+
return method(self, node)
64+
65+
@_register(ast.Expression)
66+
def _expression(self, node: ast.Expression) -> Any:
67+
return self._eval(node.body)
68+
69+
@_register(ast.BinOp)
70+
def _binop(self, node: ast.BinOp) -> Any:
71+
func = self._operators.get(type(node.op))
72+
if func is None:
73+
raise NotImplementedError
74+
return func(self._eval(node.left), self._eval(node.right))
75+
76+
@_register(ast.Constant)
77+
def _constant(self, node: ast.Constant) -> Any:
78+
return node.value
79+
80+
if sys.version_info < (3, 8):
81+
82+
# Python 3.7, replaced by ast.Constant is later versions.
83+
@_register(ast.Num)
84+
def _num(self, node: ast.Num) -> Any:
85+
return node.n
86+
87+
# Python 3.7, replaced by ast.Constant is later versions.
88+
@_register(ast.Str)
89+
def _str(self, node: ast.Str) -> Any:
90+
return node.s
91+
92+
@_register(ast.Name)
93+
def _variable(self, node: ast.Name) -> Any:
94+
value = self._variables[node.id]
95+
if callable(value):
96+
value = value()
97+
return value
98+
99+
100+
def _ncores() -> int:
101+
return 42
102+
103+
104+
class Template(string.Template):
105+
braceidpattern = r'[^}]+'
106+
107+
108+
def eval(template: str, variables: Optional[Mapping[str, Any]] = None) -> str:
109+
if variables is None:
110+
variables = {'ncores': _ncores}
111+
return Template(template).substitute(Interpreter(variables))
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# SPDX-FileCopyrightText: 2023 The meson-python developers
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
project('substitutions', version: '0.0.1')
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# SPDX-FileCopyrightText: 2023 The meson-python developers
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
[build-system]
6+
build-backend = 'mesonpy'
7+
requires = ['meson-python']
8+
9+
[tool.meson-python.args]
10+
compile = ['-j', '$x']
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# SPDX-FileCopyrightText: 2023 The meson-python developers
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
project('substitutions', version: '0.0.1')
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# SPDX-FileCopyrightText: 2023 The meson-python developers
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
[build-system]
6+
build-backend = 'mesonpy'
7+
requires = ['meson-python']
8+
9+
[tool.meson-python.args]
10+
compile = ['-j', '${ncores // 2 + 2}']

tests/test_substitutions.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# SPDX-FileCopyrightText: 2023 The meson-python developers
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
import pytest
6+
7+
import mesonpy
8+
9+
10+
def test_interpolate():
11+
assert mesonpy._substitutions.eval('$x ${foo}', {'x': 1, 'foo': 2}) == '1 2'
12+
13+
14+
def test_interpolate_expression():
15+
assert mesonpy._substitutions.eval('${(x + 2 * 3 - 1) // 3 / 2}', {'x': 1}) == '1.0'
16+
17+
18+
def test_interpolate_key_error():
19+
with pytest.raises(ValueError, match='unknown variable "y"'):
20+
mesonpy._substitutions.eval('$y', {'x': 1})
21+
22+
23+
def test_interpolate_not_implemented():
24+
with pytest.raises(ValueError, match='invalid expression'):
25+
mesonpy._substitutions.eval('${x ** 2}', {'x': 1})
26+
27+
28+
def test_substitutions(package_substitutions, monkeypatch):
29+
monkeypatch.setattr(mesonpy._substitutions, '_ncores', lambda: 2)
30+
with mesonpy._project() as project:
31+
assert project._meson_args['compile'] == ['-j', '3']
32+
33+
34+
def test_substitutions_invalid(package_substitutions_invalid, monkeypatch):
35+
monkeypatch.setattr(mesonpy._substitutions, '_ncores', lambda: 2)
36+
with pytest.raises(mesonpy.ConfigError, match=''):
37+
with mesonpy._project():
38+
pass

0 commit comments

Comments
 (0)