diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 78652a13..41be321d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -3,6 +3,16 @@ Changelog +++++++++ +Unreleased +========== + +- Add schema validation for ``build-system`` table to check conformity + with PEP 517 and PEP 518 (`PR #365`_, Fixes `#364`_) + +.. _PR #365: https://github.com/pypa/build/pull/365 +.. _#364: https://github.com/pypa/build/issues/364 + + 0.7.0 (16-09-2021) ================== diff --git a/src/build/__init__.py b/src/build/__init__.py index 9b439814..853a122c 100644 --- a/src/build/__init__.py +++ b/src/build/__init__.py @@ -94,12 +94,33 @@ def __str__(self) -> str: return f'Backend operation failed: {self.exception!r}' +class BuildSystemTableValidationError(BuildException): + """ + Exception raised when the ``[build-system]`` table in pyproject.toml is invalid. + """ + + def __str__(self) -> str: + return f'Failed to validate `build-system` in pyproject.toml: {self.args[0]}' + + class TypoWarning(Warning): """ Warning raised when a potential typo is found """ +@contextlib.contextmanager +def _working_directory(path: str) -> Iterator[None]: + current = os.getcwd() + + os.chdir(path) + + try: + yield + finally: + os.chdir(current) + + def _validate_source_directory(srcdir: str) -> None: if not os.path.isdir(srcdir): raise BuildException(f'Source {srcdir} is not a directory') @@ -153,25 +174,51 @@ def check_dependency( def _find_typo(dictionary: Mapping[str, str], expected: str) -> None: - if expected not in dictionary: - for obj in dictionary: - if difflib.SequenceMatcher(None, expected, obj).ratio() >= 0.8: - warnings.warn( - f"Found '{obj}' in pyproject.toml, did you mean '{expected}'?", - TypoWarning, - ) + for obj in dictionary: + if difflib.SequenceMatcher(None, expected, obj).ratio() >= 0.8: + warnings.warn( + f"Found '{obj}' in pyproject.toml, did you mean '{expected}'?", + TypoWarning, + ) -@contextlib.contextmanager -def _working_directory(path: str) -> Iterator[None]: - current = os.getcwd() +def _parse_build_system_table(pyproject_toml: Mapping[str, Any]) -> Dict[str, Any]: + # If pyproject.toml is missing (per PEP 517) or [build-system] is missing + # (per PEP 518), use default values + if 'build-system' not in pyproject_toml: + _find_typo(pyproject_toml, 'build-system') + return _DEFAULT_BACKEND - os.chdir(path) + build_system_table = dict(pyproject_toml['build-system']) - try: - yield - finally: - os.chdir(current) + # If [build-system] is present, it must have a ``requires`` field (per PEP 518) + if 'requires' not in build_system_table: + _find_typo(build_system_table, 'requires') + raise BuildSystemTableValidationError('`requires` is a required property') + elif not isinstance(build_system_table['requires'], list) or not all( + isinstance(i, str) for i in build_system_table['requires'] + ): + raise BuildSystemTableValidationError('`requires` must be an array of strings') + + if 'build-backend' not in build_system_table: + _find_typo(build_system_table, 'build-backend') + # If ``build-backend`` is missing, inject the legacy setuptools backend + # but leave ``requires`` intact to emulate pip + build_system_table['build-backend'] = _DEFAULT_BACKEND['build-backend'] + elif not isinstance(build_system_table['build-backend'], str): + raise BuildSystemTableValidationError('`build-backend` must be a string') + + if 'backend-path' in build_system_table and ( + not isinstance(build_system_table['backend-path'], list) + or not all(isinstance(i, str) for i in build_system_table['backend-path']) + ): + raise BuildSystemTableValidationError('`backend-path` must be an array of strings') + + unknown_props = build_system_table.keys() - {'requires', 'build-backend', 'backend-path'} + if unknown_props: + raise BuildSystemTableValidationError(f'Unknown properties: {", ".join(unknown_props)}') + + return build_system_table class ProjectBuilder: @@ -219,23 +266,7 @@ def __init__( except TOMLDecodeError as e: raise BuildException(f'Failed to parse {spec_file}: {e} ') - build_system = spec.get('build-system') - # if pyproject.toml is missing (per PEP 517) or [build-system] is missing (per PEP 518), - # use default values. - if build_system is None: - _find_typo(spec, 'build-system') - build_system = _DEFAULT_BACKEND - # if [build-system] is present, it must have a ``requires`` field (per PEP 518). - elif 'requires' not in build_system: - _find_typo(build_system, 'requires') - raise BuildException(f"Missing 'build-system.requires' in {spec_file}") - # if ``build-backend`` is missing, inject the legacy setuptools backend - # but leave ``requires`` alone to emulate pip. - elif 'build-backend' not in build_system: - _find_typo(build_system, 'build-backend') - build_system['build-backend'] = _DEFAULT_BACKEND['build-backend'] - - self._build_system = build_system + self._build_system = _parse_build_system_table(spec) self._backend = self._build_system['build-backend'] self._scripts_dir = scripts_dir self._hook_runner = runner diff --git a/tests/test_projectbuilder.py b/tests/test_projectbuilder.py index c61871e2..e03be659 100644 --- a/tests/test_projectbuilder.py +++ b/tests/test_projectbuilder.py @@ -571,3 +571,62 @@ def test_log(mocker, caplog, test_flit_path): ] if sys.version_info >= (3, 8): # stacklevel assert [(record.lineno) for record in caplog.records] == [305, 305, 338, 368, 368, 562] + + +@pytest.mark.parametrize( + ('pyproject_toml', 'parse_output'), + [ + ( + {'build-system': {'requires': ['foo']}}, + {'requires': ['foo'], 'build-backend': 'setuptools.build_meta:__legacy__'}, + ), + ( + {'build-system': {'requires': ['foo'], 'build-backend': 'bar'}}, + {'requires': ['foo'], 'build-backend': 'bar'}, + ), + ( + {'build-system': {'requires': ['foo'], 'build-backend': 'bar', 'backend-path': ['baz']}}, + {'requires': ['foo'], 'build-backend': 'bar', 'backend-path': ['baz']}, + ), + ], +) +def test_parse_valid_build_system_table_type(pyproject_toml, parse_output): + assert build._parse_build_system_table(pyproject_toml) == parse_output + + +@pytest.mark.parametrize( + ('pyproject_toml', 'error_message'), + [ + ( + {'build-system': {}}, + '`requires` is a required property', + ), + ( + {'build-system': {'requires': 'not an array'}}, + '`requires` must be an array of strings', + ), + ( + {'build-system': {'requires': [1]}}, + '`requires` must be an array of strings', + ), + ( + {'build-system': {'requires': ['foo'], 'build-backend': ['not a string']}}, + '`build-backend` must be a string', + ), + ( + {'build-system': {'requires': ['foo'], 'backend-path': 'not an array'}}, + '`backend-path` must be an array of strings', + ), + ( + {'build-system': {'requires': ['foo'], 'backend-path': [1]}}, + '`backend-path` must be an array of strings', + ), + ( + {'build-system': {'requires': ['foo'], 'unknown-prop': False}}, + 'Unknown properties: unknown-prop', + ), + ], +) +def test_parse_invalid_build_system_table_type(pyproject_toml, error_message): + with pytest.raises(build.BuildSystemTableValidationError, match=error_message): + build._parse_build_system_table(pyproject_toml)