diff --git a/meson.build b/meson.build index 0fe1f1b89..e96c7a4bc 100644 --- a/meson.build +++ b/meson.build @@ -14,6 +14,7 @@ endif py.install_sources( 'mesonpy/__init__.py', 'mesonpy/_compat.py', + 'mesonpy/_config.py', 'mesonpy/_dylib.py', 'mesonpy/_editable.py', 'mesonpy/_elf.py', diff --git a/mesonpy/__init__.py b/mesonpy/__init__.py index 6cc9a7da4..ffc2a4dd6 100644 --- a/mesonpy/__init__.py +++ b/mesonpy/__init__.py @@ -44,6 +44,7 @@ import pyproject_metadata import mesonpy._compat +import mesonpy._config import mesonpy._dylib import mesonpy._elf import mesonpy._introspection @@ -51,13 +52,14 @@ import mesonpy._util import mesonpy._wheelfile -from mesonpy._compat import Collection, Iterable, Mapping, cached_property, read_binary +from mesonpy._compat import Collection, Iterable, cached_property, read_binary +from mesonpy._config import BuildHookSettings, ToolSettings if typing.TYPE_CHECKING: # pragma: no cover from typing import Any, Callable, ClassVar, DefaultDict, List, Optional, Sequence, TextIO, Tuple, Type, TypeVar, Union - from mesonpy._compat import Iterator, Literal, ParamSpec, Path + from mesonpy._compat import Iterator, ParamSpec, Path P = ParamSpec('P') T = TypeVar('T') @@ -66,18 +68,6 @@ __version__ = '0.13.0.dev1' -# XXX: Once Python 3.8 is our minimum supported version, get rid of -# meson_args_keys and use typing.get_args(MesonArgsKeys) instead. - -# Keep both definitions in sync! -_MESON_ARGS_KEYS = ['dist', 'setup', 'compile', 'install'] -if typing.TYPE_CHECKING: - MesonArgsKeys = Literal['dist', 'setup', 'compile', 'install'] - MesonArgs = Mapping[MesonArgsKeys, List[str]] -else: - MesonArgs = None - - _COLORS = { 'red': '\33[31m', 'cyan': '\33[36m', @@ -662,22 +652,25 @@ class Project(): ] _metadata: Optional[pyproject_metadata.StandardMetadata] - def __init__( # noqa: C901 + def __init__( self, source_dir: Path, working_dir: Path, - build_dir: Optional[Path] = None, - meson_args: Optional[MesonArgs] = None, - editable_verbose: bool = False, + hook_settings: BuildHookSettings | None = None, ) -> None: + self._hook_settings = hook_settings or BuildHookSettings() self._source_dir = pathlib.Path(source_dir).absolute() self._working_dir = pathlib.Path(working_dir).absolute() - self._build_dir = pathlib.Path(build_dir).absolute() if build_dir else (self._working_dir / 'build') - self._editable_verbose = editable_verbose + + if self._hook_settings.builddir: + self._build_dir = self._hook_settings.builddir.absolute() + else: + self._build_dir = self._working_dir / 'build' + self._install_dir = self._working_dir / 'install' self._meson_native_file = self._build_dir / 'meson-python-native-file.ini' self._meson_cross_file = self._build_dir / 'meson-python-cross-file.ini' - self._meson_args: MesonArgs = collections.defaultdict(list) + self._env = os.environ.copy() # prepare environment @@ -686,6 +679,29 @@ def __init__( # noqa: C901 raise ConfigError(f'Could not find ninja version {_NINJA_REQUIRED_VERSION} or newer.') self._env.setdefault('NINJA', self._ninja) + # load config -- PEP 621 support is optional + pyproject_data = tomllib.loads(self._source_dir.joinpath('pyproject.toml').read_text()) + self._pep621 = 'project' in pyproject_data + if self.pep621: + self._metadata = pyproject_metadata.StandardMetadata.from_pyproject(pyproject_data, self._source_dir) + else: + print( + '{yellow}{bold}! Using Meson to generate the project metadata ' + '(no `project` section in pyproject.toml){reset}'.format(**_STYLES) + ) + self._metadata = None + + if self._metadata: + self._validate_metadata() + + # load meson args + self._config = ToolSettings.from_pyproject(pyproject_data) + self._meson_args = self._config.meson_args + self._hook_settings.meson_args + + # make sure the build dir exists + self._build_dir.mkdir(exist_ok=True, parents=True) + self._install_dir.mkdir(exist_ok=True, parents=True) + # setuptools-like ARCHFLAGS environment variable support if sysconfig.get_platform().startswith('macosx-'): archflags = self._env.get('ARCHFLAGS') @@ -710,41 +726,7 @@ def __init__( # noqa: C901 endian = 'little' ''') self._meson_cross_file.write_text(cross_file_data) - self._meson_args['setup'].extend(('--cross-file', os.fspath(self._meson_cross_file))) - - # load config -- PEP 621 support is optional - self._config = tomllib.loads(self._source_dir.joinpath('pyproject.toml').read_text()) - self._pep621 = 'project' in self._config - if self.pep621: - self._metadata = pyproject_metadata.StandardMetadata.from_pyproject(self._config, self._source_dir) - else: - print( - '{yellow}{bold}! Using Meson to generate the project metadata ' - '(no `project` section in pyproject.toml){reset}'.format(**_STYLES) - ) - self._metadata = None - - if self._metadata: - self._validate_metadata() - - # load meson args - for key in self._get_config_key('args'): - self._meson_args[key].extend(self._get_config_key(f'args.{key}')) - # XXX: We should validate the user args to make sure they don't conflict with ours. - - self._check_for_unknown_config_keys({ - 'args': _MESON_ARGS_KEYS, - }) - - # meson arguments from the command line take precedence over - # arguments from the configuration file thus are added later - if meson_args: - for key, value in meson_args.items(): - self._meson_args[key].extend(value) - - # make sure the build dir exists - self._build_dir.mkdir(exist_ok=True, parents=True) - self._install_dir.mkdir(exist_ok=True, parents=True) + self._config.meson_args.setup.extend(('--cross-file', os.fspath(self._meson_cross_file))) # write the native file native_file_data = textwrap.dedent(f''' @@ -768,14 +750,6 @@ def __init__( # noqa: C901 if self._metadata and 'version' in self._metadata.dynamic: self._metadata.version = self.version - def _get_config_key(self, key: str) -> Any: - value: Any = self._config - for part in f'tool.meson-python.{key}'.split('.'): - if not isinstance(value, Mapping): - raise ConfigError(f'Configuration entry "tool.meson-python.{key}" should be a TOML table not {type(value)}') - value = value.get(part, {}) - return value - def _run(self, cmd: Sequence[str]) -> None: """Invoke a subprocess.""" print('{cyan}{bold}+ {}{reset}'.format(' '.join(cmd), **_STYLES)) @@ -804,7 +778,7 @@ def _configure(self, reconfigure: bool = False) -> None: sys_paths['platlib'], # user args - *self._meson_args['setup'], + *self._meson_args.setup, ] if reconfigure: setup_args.insert(0, '--reconfigure') @@ -834,17 +808,6 @@ def _validate_metadata(self) -> None: f'expected {self._metadata.requires_python}' ) - def _check_for_unknown_config_keys(self, valid_args: Mapping[str, Collection[str]]) -> None: - config = self._config.get('tool', {}).get('meson-python', {}) - - for key, valid_subkeys in config.items(): - if key not in valid_args: - raise ConfigError(f'Unknown configuration key "tool.meson-python.{key}"') - - for subkey in valid_args[key]: - if subkey not in valid_subkeys: - raise ConfigError(f'Unknown configuration key "tool.meson-python.{key}.{subkey}"') - @cached_property def _wheel_builder(self) -> _WheelBuilder: return _WheelBuilder( @@ -860,14 +823,14 @@ def _wheel_builder(self) -> _WheelBuilder: def build_commands(self, install_dir: Optional[pathlib.Path] = None) -> Sequence[Sequence[str]]: assert self._ninja is not None # help mypy out return ( - (self._ninja, *self._meson_args['compile'],), + (self._ninja, *self._meson_args.compile,), ( 'meson', 'install', '--only-changed', '--destdir', os.fspath(install_dir or self._install_dir), - *self._meson_args['install'], + *self._meson_args.install, ), ) @@ -881,14 +844,12 @@ def build(self) -> None: @contextlib.contextmanager def with_temp_working_dir( cls, + hook_settings: BuildHookSettings | None = None, source_dir: Path = os.path.curdir, - build_dir: Optional[Path] = None, - meson_args: Optional[MesonArgs] = None, - editable_verbose: bool = False, ) -> Iterator[Project]: """Creates a project instance pointing to a temporary working directory.""" with tempfile.TemporaryDirectory(prefix='.mesonpy-', dir=os.fspath(source_dir)) as tmpdir: - yield cls(source_dir, tmpdir, build_dir, meson_args, editable_verbose) + yield cls(source_dir, tmpdir, hook_settings) @functools.lru_cache() def _info(self, name: str) -> Dict[str, Any]: @@ -909,7 +870,7 @@ def _install_plan(self) -> Dict[str, Dict[str, Dict[str, str]]]: # parse install args for install tags (--tags) parser = argparse.ArgumentParser() parser.add_argument('--tags') - args, _ = parser.parse_known_args(self._meson_args['install']) + args, _ = parser.parse_known_args(self._meson_args.install) # filter the install_plan for files that do not fit the install tags if args.tags: @@ -1010,7 +971,7 @@ def pep621(self) -> bool: def sdist(self, directory: Path) -> pathlib.Path: """Generates a sdist (source distribution) in the specified directory.""" # generate meson dist file - self._run(['meson', 'dist', '--allow-dirty', '--no-tests', '--formats', 'gztar', *self._meson_args['dist']]) + self._run(['meson', 'dist', '--allow-dirty', '--no-tests', '--formats', 'gztar', *self._meson_args.dist]) # move meson dist file to output path dist_name = f'{self.name}-{self.version}' @@ -1074,7 +1035,7 @@ def wheel(self, directory: Path) -> pathlib.Path: return file def editable(self, directory: Path) -> pathlib.Path: - file = self._wheel_builder.build_editable(directory, self._editable_verbose) + file = self._wheel_builder.build_editable(directory, self._hook_settings.editable_verbose) assert isinstance(file, pathlib.Path) return file @@ -1091,51 +1052,10 @@ def _project(config_settings: Optional[Dict[Any, Any]]) -> Iterator[Project]: for key, value in config_settings.items() } - builddir_value = config_settings.get('builddir', {}) - if len(builddir_value) > 0: - if len(builddir_value) != 1: - raise ConfigError('Only one value for configuration entry "builddir" can be specified') - builddir = builddir_value[0] - if not isinstance(builddir, str): - raise ConfigError(f'Configuration entry "builddir" should be a string not {type(builddir)}') - else: - builddir = None - - def _validate_string_collection(key: str) -> None: - assert isinstance(config_settings, Mapping) - problematic_items: Sequence[Any] = list(filter(None, ( - item if not isinstance(item, str) else None - for item in config_settings.get(key, ()) - ))) - if problematic_items: - s = ', '.join(f'"{item}" ({type(item)})' for item in problematic_items) - raise ConfigError(f'Configuration entries for "{key}" must be strings but contain: {s}') - - meson_args_keys = _MESON_ARGS_KEYS - meson_args_cli_keys = tuple(f'{key}-args' for key in meson_args_keys) - - for key in config_settings: - known_keys = ('builddir', 'editable-verbose', *meson_args_cli_keys) - if key not in known_keys: - import difflib - matches = difflib.get_close_matches(key, known_keys, n=3) - if len(matches): - alternatives = ' or '.join(f'"{match}"' for match in matches) - raise ConfigError(f'Unknown configuration entry "{key}". Did you mean {alternatives}?') - else: - raise ConfigError(f'Unknown configuration entry "{key}"') - - for key in meson_args_cli_keys: - _validate_string_collection(key) - - with Project.with_temp_working_dir( - build_dir=builddir, - meson_args=typing.cast(MesonArgs, { - key: config_settings.get(f'{key}-args', ()) - for key in meson_args_keys - }), - editable_verbose=bool(config_settings.get('editable-verbose')) - ) as project: + hook_settings = BuildHookSettings.from_config_settings(config_settings) + print(config_settings, hook_settings) + + with Project.with_temp_working_dir(hook_settings) as project: yield project @@ -1169,8 +1089,13 @@ def _pyproject_hook(func: Callable[P, T]) -> Callable[P, T]: def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: try: return func(*args, **kwargs) - except Error as exc: - print('{red}meson-python: error:{reset} {msg}'.format(msg=str(exc), **_STYLES)) + except (Error, pyproject_metadata.ConfigurationError) as exc: + print(( + '{red}meson-python: error:{reset}\n' + '{red}>{reset}\n' + + textwrap.indent(str(exc).strip(), prefix='{red}>{reset} ') + '\n' + + '{red}>{reset}\n' + ).format(**_STYLES)) raise SystemExit(1) from exc return wrapper diff --git a/mesonpy/_config.py b/mesonpy/_config.py new file mode 100644 index 000000000..acacbbde9 --- /dev/null +++ b/mesonpy/_config.py @@ -0,0 +1,213 @@ + +# SPDX-FileCopyrightText: 2023 The meson-python developers +# +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +import abc +import dataclasses +import difflib +import functools +import itertools +import pathlib +import typing + +import pyproject_metadata + +import mesonpy._util + +from mesonpy._compat import Mapping + + +if typing.TYPE_CHECKING: + from typing import Any, Callable + + from mesonpy._compat import Collection, Iterator, Sequence + + + +class DataFetcher(pyproject_metadata.DataFetcher): # type: ignore[misc] + """Custom data fetcher that provides a key mapping functionality.""" + def __init__(self, data: Mapping[str, Any], key_map: Callable[[str], str] | None = None) -> None: + super().__init__(data) + self._key_map = key_map + + def get(self, key: str) -> Any: + if self._key_map: + key = self._key_map(key) + val = super().get(key) + # XXX: convert tuples to lists because get_list currently does not accept tuples + if isinstance(val, tuple): + return list(val) + return val + + +@dataclasses.dataclass +class MesonArgs: + """Data class that holds the user configurable Meson arguments settings.""" + dist: list[str] = dataclasses.field(default_factory=list) + setup: list[str] = dataclasses.field(default_factory=list) + compile: list[str] = dataclasses.field(default_factory=list) + install: list[str] = dataclasses.field(default_factory=list) + + def __add__(self, other: Any) -> MesonArgs: + if not isinstance(other, self.__class__): + raise TypeError(f'Invalid type: {type(other)}') + return self.__class__( + self.dist + other.dist, + self.setup + other.setup, + self.compile + other.compile, + self.install + other.install, + ) + + @classmethod + def from_fetcher(cls, fetcher: pyproject_metadata.DataFetcher) -> MesonArgs: + """Create an instance from a data fetcher object.""" + return cls( + fetcher.get_list('dist'), + fetcher.get_list('setup'), + fetcher.get_list('compile'), + fetcher.get_list('install'), + ) + + +class StrictSettings(abc.ABC): + """Helper class that provides unknown keys checks.""" + + @classmethod + def _get_key(cls, data: Mapping[Any, Any], key: str) -> Mapping[Any, Any]: + try: + for part in key.split('.'): + data = data[part] + except KeyError: + return {} + return data + + @classmethod + @functools.lru_cache(maxsize=None) + def _expected_keys(cls) -> Collection[str]: + """Calculates an expanded list of all the possible expected key. + + Eg. If we had the 'a.b.c.d.e.f' and 'a.b.c.xxx' keys, it would generate + 'a', 'a.b', 'a.b.c', 'a.b.c.d', 'a.b.c.xxx', 'a.b.c.d.e', and 'a.b.c.d.e.f'. + """ + def join(*args: str) -> str: + return '.'.join(args) + + return frozenset(itertools.chain.from_iterable( + itertools.accumulate(key.split('.'), join) + for key in cls.valid_keys() + )) + + @classmethod + @functools.lru_cache(maxsize=None) + def _valid_keys(cls) -> Collection[str]: + """Caches the valid_keys() method implemented by the children.""" + return frozenset(cls.valid_keys()) + + @classmethod + def _unexpected_keys(cls, data: Any, key: str = '') -> Iterator[tuple[str, Sequence[str]]]: + """Iterates over the unexpected keys in a data object.""" + if key and key not in cls._expected_keys(): + yield key, sorted(difflib.get_close_matches(key, cls._valid_keys(), n=3)) + elif isinstance(data, Mapping): + for name, value in data.items(): + yield from cls._unexpected_keys(value, f'{key}.{name}' if key else name) + + @classmethod + def check_for_unexpected_keys(cls, data: Mapping[Any, Any], active_prefix: str = '') -> None: + """Checks if there are any unexpected keys in a data object.""" + if active_prefix: + data = cls._get_key(data, active_prefix) + unknown = list(cls._unexpected_keys(data, active_prefix)) + if not unknown: + return + error_msg = 'The following unknown configuration entries were found:\n' + for key, matches in unknown: + error_msg += f'\t- {key}' + if matches: + match_list = mesonpy._util.natural_language_list(matches, conjunction='or') + error_msg += f' (did you mean {match_list}?)' + error_msg += '\n' + raise pyproject_metadata.ConfigurationError(error_msg) + + @classmethod + @abc.abstractmethod + def valid_keys(cls) -> Iterator[str]: + """Provides a list of all valid keys.""" + + +@dataclasses.dataclass +class BuildHookSettings(StrictSettings): + """build_wheel hook config_settings.""" + builddir: pathlib.Path | None = None + meson_args: MesonArgs = dataclasses.field(default_factory=MesonArgs) + editable_verbose: bool = False + + @classmethod + def _key_map(cls, key: str) -> str: + return key.replace('_', '-') + + @classmethod + def _meson_args_key_map(cls, key: str) -> str: + return f'{key}-args' + + @classmethod + def valid_keys(cls) -> Iterator[str]: + for field in dataclasses.fields(cls): + if field.name == 'meson_args': + continue + yield cls._key_map(field.name) + for field in dataclasses.fields(MesonArgs): + yield cls._meson_args_key_map(field.name) + + @classmethod + def from_config_settings(cls, data: Mapping[str, Any]) -> BuildHookSettings: + cls.check_for_unexpected_keys(data) + + fetcher = DataFetcher(data) + try: + builddir = fetcher.get('builddir') + except KeyError: + builddir = None + if isinstance(builddir, list): + if len(builddir) >= 2: + raise pyproject_metadata.ConfigurationError( + 'Only one value for configuration entry "builddir" can be specified' + ) + builddir = builddir[0] + if builddir is not None and not isinstance(builddir, str): + raise pyproject_metadata.ConfigurationError( + 'Configuration entry "builddir" has an invalid type, ' + f'expecting a string (got "{builddir}")' + ) + + return cls( + pathlib.Path(builddir) if builddir else None, + MesonArgs.from_fetcher(DataFetcher(data, cls._meson_args_key_map)), + bool(fetcher.get_str('editable-verbose')), + ) + + +@dataclasses.dataclass +class ToolSettings(StrictSettings): + """pyproject.toml settings.""" + meson_args: MesonArgs + + @classmethod + def _meson_args_key_map(cls, key: str) -> str: + return f'tool.meson-python.args.{key}' + + @classmethod + def valid_keys(cls) -> Iterator[str]: + for field in dataclasses.fields(MesonArgs): + yield cls._meson_args_key_map(field.name) + + @classmethod + def from_pyproject(cls, data: Mapping[str, Any]) -> ToolSettings: + cls.check_for_unexpected_keys(data, 'tool.meson-python') + + return cls( + MesonArgs.from_fetcher(DataFetcher(data, cls._meson_args_key_map)), + ) diff --git a/mesonpy/_util.py b/mesonpy/_util.py index fb7b2c51c..b1b427187 100644 --- a/mesonpy/_util.py +++ b/mesonpy/_util.py @@ -19,7 +19,7 @@ if typing.TYPE_CHECKING: # pragma: no cover from typing import Optional, Tuple - from mesonpy._compat import Iterable, Iterator, Path + from mesonpy._compat import Iterable, Iterator, Path, Sequence @contextlib.contextmanager @@ -96,3 +96,20 @@ def cli_counter(total: int) -> Iterator[CLICounter]: counter = CLICounter(total) yield counter counter.finish() + + +def natural_language_list(elements: Sequence[str], quote: str = '"', conjunction: str = 'and') -> str: + oxford_comma = ',' + elements = [f'{quote}{x}{quote}' for x in elements] + if len(elements) == 0: + raise IndexError('no elements') + elif len(elements) == 1: + return elements[0] + elif len(elements) == 2: + oxford_comma = '' + return '{}{} {} {}'.format( + ', '.join(elements[:-1]), + oxford_comma, + conjunction, + elements[-1], + ) diff --git a/tests/test_pep517.py b/tests/test_pep517.py index 6e14227d6..7d8b86406 100644 --- a/tests/test_pep517.py +++ b/tests/test_pep517.py @@ -66,8 +66,8 @@ def test_invalid_config_settings(capsys, package_pure, tmp_path_session): with pytest.raises(SystemExit): method(tmp_path_session, {'invalid': ()}) out, err = capsys.readouterr() - assert out.splitlines()[-1].endswith( - 'Unknown configuration entry "invalid"') + assert 'The following unknown configuration entries were found:' in out + assert '- invalid' in out def test_invalid_config_settings_suggest(capsys, package_pure, tmp_path_session): @@ -75,5 +75,5 @@ def test_invalid_config_settings_suggest(capsys, package_pure, tmp_path_session) with pytest.raises(SystemExit): method(tmp_path_session, {'setup_args': ()}) out, err = capsys.readouterr() - assert out.splitlines()[-1].endswith( - 'Unknown configuration entry "setup_args". Did you mean "setup-args" or "dist-args"?') + assert 'The following unknown configuration entries were found:' in out + assert '- setup_args (did you mean "dist-args" or "setup-args"?)' in out diff --git a/tests/test_project.py b/tests/test_project.py index 650b4f43c..91b1d97f6 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -4,10 +4,13 @@ import platform +import pyproject_metadata import pytest import mesonpy +from mesonpy._config import BuildHookSettings + from .conftest import chdir, package_dir @@ -86,7 +89,7 @@ def last_two_meson_args(): @pytest.mark.parametrize('package', ('top-level', 'meson-args')) def test_unknown_user_args(package, tmp_path_session): - with pytest.raises(mesonpy.ConfigError): + with pytest.raises(pyproject_metadata.ConfigurationError): mesonpy.Project(package_dir / f'unknown-user-args-{package}', tmp_path_session) @@ -94,8 +97,8 @@ def test_install_tags(package_purelib_and_platlib, tmp_path_session): project = mesonpy.Project( package_purelib_and_platlib, tmp_path_session, - meson_args={ - 'install': ['--tags', 'purelib'], - } + hook_settings=BuildHookSettings.from_config_settings({ + 'install-args': ['--tags', 'purelib'], + }), ) assert project.is_pure