diff --git a/mesonpy/__init__.py b/mesonpy/__init__.py index 8326762ba..27e22d335 100644 --- a/mesonpy/__init__.py +++ b/mesonpy/__init__.py @@ -32,8 +32,8 @@ import warnings from typing import ( - Any, ClassVar, DefaultDict, Dict, List, Optional, Sequence, Set, TextIO, - Tuple, Type, Union + Any, Callable, ClassVar, DefaultDict, Dict, List, Optional, Sequence, Set, + TextIO, Tuple, Type, TypeVar, Union ) @@ -49,7 +49,7 @@ import mesonpy._wheelfile from mesonpy._compat import ( - Collection, Iterator, Literal, Mapping, Path, typing_get_args + Collection, Iterator, Literal, Mapping, ParamSpec, Path, typing_get_args ) @@ -67,6 +67,7 @@ _COLORS = { + 'red': '\33[31m', 'cyan': '\33[36m', 'yellow': '\33[93m', 'light_blue': '\33[94m', @@ -137,11 +138,16 @@ def _setup_cli() -> None: colorama.init() # fix colors on windows -class ConfigError(Exception): +class Error(RuntimeError): + def __str__(self) -> str: + return str(self.args[0]) + + +class ConfigError(Error): """Error in the backend configuration.""" -class MesonBuilderError(Exception): +class MesonBuilderError(Error): """Error when building the Meson package.""" @@ -581,7 +587,7 @@ def __init__( # noqa: C901 if archflags is not None: arch, *other = filter(None, (x.strip() for x in archflags.split('-arch'))) if other: - raise ConfigError(f'multi-architecture builds are not supported but $ARCHFLAGS={archflags!r}') + raise ConfigError(f'Multi-architecture builds are not supported but $ARCHFLAGS={archflags!r}') macver, _, nativearch = platform.mac_ver() if arch != nativearch: x = self._env.setdefault('_PYTHON_HOST_PLATFORM', f'macosx-{macver}-{arch}') @@ -670,17 +676,16 @@ 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'Found unexpected value in `{part}` when looking for ' - f'config key `tool.meson-python.{key}` (`{value}`)' - ) + raise ConfigError(f'Configuration entry "tool.meson-python.{key}" should be a TOML table not {type(value)}') value = value.get(part, {}) return value def _proc(self, *args: str) -> None: """Invoke a subprocess.""" print('{cyan}{bold}+ {}{reset}'.format(' '.join(args), **_STYLES)) - subprocess.check_call(list(args), env=self._env) + r = subprocess.run(list(args), env=self._env) + if r.returncode != 0: + raise SystemExit(r.returncode) def _meson(self, *args: str) -> None: """Invoke Meson.""" @@ -726,17 +731,16 @@ def _validate_metadata(self) -> None: if key not in self._ALLOWED_DYNAMIC_FIELDS } if unsupported_dynamic: - raise MesonBuilderError('Unsupported dynamic fields: {}'.format( - ', '.join(unsupported_dynamic)), - ) + s = ', '.join(f'"{x}"' for x in unsupported_dynamic) + raise MesonBuilderError(f'Unsupported dynamic fields: {s}') # check if we are running on an unsupported interpreter if self._metadata.requires_python: self._metadata.requires_python.prereleases = True if platform.python_version().rstrip('+') not in self._metadata.requires_python: raise MesonBuilderError( - f'Unsupported Python version `{platform.python_version()}`, ' - f'expected `{self._metadata.requires_python}`' + f'Unsupported Python version {platform.python_version()}, ' + f'expected {self._metadata.requires_python}' ) def _check_for_unknown_config_keys(self, valid_args: Mapping[str, Collection[str]]) -> None: @@ -744,11 +748,11 @@ def _check_for_unknown_config_keys(self, valid_args: Mapping[str, Collection[str for key, valid_subkeys in config.items(): if key not in valid_args: - raise ConfigError(f'Unknown configuration key: tool.meson-python.{key}') + 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}') + raise ConfigError(f'Unknown configuration key "tool.meson-python.{key}.{subkey}"') @cached_property def _wheel_builder(self) -> _WheelBuilder: @@ -970,10 +974,10 @@ def _project(config_settings: Optional[Dict[Any, Any]]) -> Iterator[Project]: builddir_value = config_settings.get('builddir', {}) if len(builddir_value) > 0: if len(builddir_value) != 1: - raise ConfigError('Specified multiple values for `builddir`, only one is allowed') + raise ConfigError('Only one value for configuration entry "builddir" can be specified') builddir = builddir_value[0] if not isinstance(builddir, str): - raise ConfigError(f'Config option `builddir` should be a string (found `{type(builddir)}`)') + raise ConfigError(f'Configuration entry "builddir" should be a string not {type(builddir)}') else: builddir = None @@ -984,14 +988,8 @@ def _validate_string_collection(key: str) -> None: for item in config_settings.get(key, ()) ))) if problematic_items: - raise ConfigError( - f'Config option `{key}` should only contain string items, but ' - 'contains the following parameters that do not meet this criteria:' + - ''.join(( - f'\t- {item} (type: {type(item)})' - for item in 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 = typing_get_args(MesonArgsKeys) meson_args_cli_keys = tuple(f'{key}-args' for key in meson_args_keys) @@ -1002,10 +1000,10 @@ def _validate_string_collection(key: str) -> None: import difflib matches = difflib.get_close_matches(key, known_keys, n=3) if len(matches): - postfix = f'Did you mean one of: {matches}' + alternatives = ' or '.join(f'"{match}"' for match in matches) + raise ConfigError(f'Unknown configuration entry "{key}". Did you mean {alternatives}?') else: - postfix = 'There are no close valid keys.' - raise ConfigError(f'Unknown config setting: {key!r}. {postfix}') + raise ConfigError(f'Unknown configuration entry "{key}"') for key in meson_args_cli_keys: _validate_string_collection(key) @@ -1045,12 +1043,29 @@ def _env_ninja_command(*, version: str = _NINJA_REQUIRED_VERSION) -> Optional[pa return None +P = ParamSpec('P') +T = TypeVar('T') + + +def _pyproject_hook(func: Callable[P, T]) -> Callable[P, T]: + @functools.wraps(func) + 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)) + raise SystemExit(1) + return wrapper + + +@_pyproject_hook def get_requires_for_build_sdist( config_settings: Optional[Dict[str, str]] = None, ) -> List[str]: return [_depstr.ninja] if _env_ninja_command() is None else [] +@_pyproject_hook def build_sdist( sdist_directory: str, config_settings: Optional[Dict[Any, Any]] = None, @@ -1062,6 +1077,7 @@ def build_sdist( return project.sdist(out).name +@_pyproject_hook def get_requires_for_build_wheel( config_settings: Optional[Dict[str, str]] = None, ) -> List[str]: @@ -1087,6 +1103,7 @@ def get_requires_for_build_wheel( return dependencies +@_pyproject_hook def build_wheel( wheel_directory: str, config_settings: Optional[Dict[Any, Any]] = None, diff --git a/mesonpy/_compat.py b/mesonpy/_compat.py index df341a51d..852dbeade 100644 --- a/mesonpy/_compat.py +++ b/mesonpy/_compat.py @@ -9,6 +9,11 @@ from typing import Union +if sys.version_info >= (3, 10): + from typing import ParamSpec +else: + from typing_extensions import ParamSpec + if sys.version_info >= (3, 9): from collections.abc import ( Collection, Iterable, Iterator, Mapping, Sequence @@ -46,5 +51,6 @@ def is_relative_to(path: pathlib.Path, other: Union[pathlib.Path, str]) -> bool: 'Literal', 'Mapping', 'Path', + 'ParamSpec', 'Sequence', ] diff --git a/pyproject.toml b/pyproject.toml index 206cc5040..deb250f10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ requires = [ 'meson>=0.63.3', 'pyproject-metadata>=0.6.1', 'tomli>=1.0.0; python_version<"3.11"', - 'typing-extensions>=3.7.4; python_version<"3.8"', + 'typing-extensions>=3.7.4; python_version<"3.10"', ] [project] @@ -28,7 +28,7 @@ dependencies = [ 'meson>=0.63.3', 'pyproject-metadata>=0.6.1', # not a hard dependency, only needed for projects that use PEP 621 metadata 'tomli>=1.0.0; python_version<"3.11"', - 'typing-extensions>=3.7.4; python_version<"3.8"', + 'typing-extensions>=3.7.4; python_version<"3.10"', ] dynamic = [ diff --git a/tests/test_examples.py b/tests/test_examples.py index 2b32b4658..14cfbb1f5 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -17,7 +17,7 @@ def test_spam(venv, tmp_path): with chdir(examples_dir / 'spam'): if sys.version_info < (3, 8): # The test project requires Python >= 3.8. - with pytest.raises(mesonpy.MesonBuilderError, match=r'Unsupported Python version `3.7.\d+`'): + with pytest.raises(SystemExit): mesonpy.build_wheel(tmp_path) else: wheel = mesonpy.build_wheel(tmp_path) diff --git a/tests/test_pep517.py b/tests/test_pep517.py index bf69921a0..49e375c9b 100644 --- a/tests/test_pep517.py +++ b/tests/test_pep517.py @@ -29,11 +29,12 @@ def which(prog: str) -> bool: # smoke check for the future if we add another usage raise AssertionError(f'Called with {prog}, tests not expecting that usage') + subprocess_run = subprocess.run + def run(cmd: List[str], *args: object, **kwargs: object) -> subprocess.CompletedProcess: - if cmd != ['ninja', '--version']: - # smoke check for the future if we add another usage - raise AssertionError(f'Called with {cmd}, tests not expecting that usage') - return subprocess.CompletedProcess(cmd, 0, f'{ninja}\n', '') + if cmd == ['ninja', '--version']: + return subprocess.CompletedProcess(cmd, 0, f'{ninja}\n', '') + return subprocess_run(cmd, *args, **kwargs) monkeypatch.setattr(shutil, 'which', which) monkeypatch.setattr(subprocess, 'run', run) @@ -55,23 +56,19 @@ def run(cmd: List[str], *args: object, **kwargs: object) -> subprocess.Completed assert set(mesonpy.get_requires_for_build_wheel()) == expected -def test_invalid_config_settings(package_pure, tmp_path_session): - raises_error = pytest.raises(mesonpy.ConfigError, match="Unknown config setting: 'invalid'") - - with raises_error: - mesonpy.build_sdist(tmp_path_session, {'invalid': ()}) - with raises_error: - mesonpy.build_wheel(tmp_path_session, {'invalid': ()}) - - -def test_invalid_config_settings_suggest(package_pure, tmp_path_session): - raises_error = pytest.raises( - mesonpy.ConfigError, - match='Did you mean one of: .*setup-args' - ) +def test_invalid_config_settings(capsys, package_pure, tmp_path_session): + for method in mesonpy.build_sdist, mesonpy.build_wheel: + with pytest.raises(SystemExit): + method(tmp_path_session, {'invalid': ()}) + out, err = capsys.readouterr() + assert out.splitlines()[-1].endswith( + 'Unknown configuration entry "invalid"') - with raises_error: - mesonpy.build_sdist(tmp_path_session, {'setup_args': ()}) - with raises_error: - mesonpy.build_wheel(tmp_path_session, {'setup_args': ()}) +def test_invalid_config_settings_suggest(capsys, package_pure, tmp_path_session): + for method in mesonpy.build_sdist, mesonpy.build_wheel: + 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"?') diff --git a/tests/test_project.py b/tests/test_project.py index b90ea87c4..4cfb177b2 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -37,15 +37,14 @@ def test_version(package): def test_unsupported_dynamic(package_unsupported_dynamic): - with pytest.raises(mesonpy.MesonBuilderError, match='Unsupported dynamic fields: dependencies'): + with pytest.raises(mesonpy.MesonBuilderError, match='Unsupported dynamic fields: "dependencies"'): with mesonpy.Project.with_temp_working_dir(): pass def test_unsupported_python_version(package_unsupported_python_version): with pytest.raises(mesonpy.MesonBuilderError, match=( - f'Unsupported Python version `{platform.python_version()}`, ' - 'expected `==1.0.0`' + f'Unsupported Python version {platform.python_version()}, expected ==1.0.0' )): with mesonpy.Project.with_temp_working_dir(): pass