From 3e659e00ade41547285b366afee3faf9a61d4e9f Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Sun, 12 Dec 2021 07:46:59 -0700 Subject: [PATCH] build: add circular dependency checker for build requirements Implement a basic build requirement cycle detector per PEP-517: - Project build requirements will define a directed graph of requirements (project A needs B to build, B needs C and D, etc.) This graph MUST NOT contain cycles. If (due to lack of co-ordination between projects, for example) a cycle is present, front ends MAY refuse to build the project. - Front ends SHOULD check explicitly for requirement cycles, and terminate the build with an informative message if one is found. See: https://www.python.org/dev/peps/pep-0517/#build-requirements --- src/build/__init__.py | 102 ++++++++++++++++-- src/build/__main__.py | 19 +++- src/build/_exceptions.py | 52 +++++++++ src/build/_util.py | 42 +++++++- .../test-circular-get-requires/backend.py | 17 +++ .../test-circular-get-requires/pyproject.toml | 9 ++ .../test-circular-metadata/backend.py | 46 ++++++++ .../test-circular-metadata/pyproject.toml | 4 + .../test-circular-requirements/pyproject.toml | 7 ++ .../test-circular-wheel/pyproject.toml | 3 + tests/packages/test-circular-wheel/setup.cfg | 7 ++ tests/test_main.py | 5 +- tests/test_projectbuilder.py | 72 ++++++++++++- 13 files changed, 364 insertions(+), 21 deletions(-) create mode 100644 tests/packages/test-circular-get-requires/backend.py create mode 100644 tests/packages/test-circular-get-requires/pyproject.toml create mode 100644 tests/packages/test-circular-metadata/backend.py create mode 100644 tests/packages/test-circular-metadata/pyproject.toml create mode 100644 tests/packages/test-circular-requirements/pyproject.toml create mode 100644 tests/packages/test-circular-wheel/pyproject.toml create mode 100644 tests/packages/test-circular-wheel/setup.cfg diff --git a/src/build/__init__.py b/src/build/__init__.py index fda07680..e1b8a820 100644 --- a/src/build/__init__.py +++ b/src/build/__init__.py @@ -21,6 +21,7 @@ from collections.abc import Iterator from typing import Any, Callable, Mapping, Optional, Sequence, TypeVar, Union +import packaging.utils import pyproject_hooks from . import env @@ -28,11 +29,13 @@ BuildBackendException, BuildException, BuildSystemTableValidationError, + CircularBuildDependencyError, FailedProcessError, + ProjectTableValidationError, TypoWarning, + ProjectNameValidationError, ) -from ._util import check_dependency, parse_wheel_filename - +from ._util import check_dependency, parse_wheel_filename, project_name_from_path if sys.version_info >= (3, 11): import tomllib @@ -126,6 +129,23 @@ def _parse_build_system_table(pyproject_toml: Mapping[str, Any]) -> Mapping[str, return build_system_table +def _parse_project_name(pyproject_toml: Mapping[str, Any]) -> str | None: + if 'project' not in pyproject_toml: + return None + + project_table = dict(pyproject_toml['project']) + + # If [project] is present, it must have a ``name`` field (per PEP 621) + if 'name' not in project_table: + raise ProjectTableValidationError('`project` must have a `name` field') + + project_name = project_table['name'] + if not isinstance(project_name, str): + raise ProjectTableValidationError('`name` field in `project` must be a string') + + return project_name + + def _wrap_subprocess_runner(runner: RunnerType, env: env.IsolatedEnv) -> RunnerType: def _invoke_wrapped_runner(cmd: Sequence[str], cwd: str | None, extra_environ: Mapping[str, str] | None) -> None: runner(cmd, cwd, {**(env.make_extra_environ() or {}), **(extra_environ or {})}) @@ -168,10 +188,18 @@ def __init__( self._runner = runner pyproject_toml_path = os.path.join(source_dir, 'pyproject.toml') - self._build_system = _parse_build_system_table(_read_pyproject_toml(pyproject_toml_path)) - + pyproject_toml = _read_pyproject_toml(pyproject_toml_path) + self._build_system = _parse_build_system_table(pyproject_toml) + + self._project_name: str | None = None + self._project_name_source: str | None = None + project_name = _parse_project_name(pyproject_toml) + if project_name: + self._update_project_name(project_name, 'pyproject.toml [project] table') self._backend = self._build_system['build-backend'] + self._check_dependencies_incomplete: dict[str, bool] = {'wheel': False, 'sdist': False} + self._hook = pyproject_hooks.BuildBackendHookCaller( self._source_dir, self._backend, @@ -198,6 +226,15 @@ def source_dir(self) -> str: """Project source directory.""" return self._source_dir + @property + def project_name(self) -> str | None: + """ + The canonicalized project name. + """ + if self._project_name is not None: + return packaging.utils.canonicalize_name(self._project_name) + return None + @property def python_executable(self) -> str: """ @@ -214,7 +251,9 @@ def build_system_requires(self) -> set[str]: """ return set(self._build_system['requires']) - def get_requires_for_build(self, distribution: str, config_settings: ConfigSettingsType | None = None) -> set[str]: + def get_requires_for_build( + self, distribution: str, config_settings: ConfigSettingsType | None = None, finalize: bool = False + ) -> set[str]: """ Return the dependencies defined by the backend in addition to :attr:`build_system_requires` for a given distribution. @@ -223,14 +262,26 @@ def get_requires_for_build(self, distribution: str, config_settings: ConfigSetti (``sdist`` or ``wheel``) :param config_settings: Config settings for the build backend """ - self.log(f'Getting build dependencies for {distribution}...') + if not finalize: + self.log(f'Getting build dependencies for {distribution}...') hook_name = f'get_requires_for_build_{distribution}' get_requires = getattr(self._hook, hook_name) with self._handle_backend(hook_name): return set(get_requires(config_settings)) - def check_dependencies(self, distribution: str, config_settings: ConfigSettingsType | None = None) -> set[tuple[str, ...]]: + def check_build_system_dependencies(self) -> set[tuple[str, ...]]: + """ + Return the dependencies which are not satisfied from + :attr:`build_system_requires` + + :returns: Set of variable-length unmet dependency tuples + """ + return {u for d in self.build_system_requires for u in check_dependency(d, project_name=self._project_name)} + + def check_dependencies( + self, distribution: str, config_settings: ConfigSettingsType | None = None, finalize: bool = False + ) -> set[tuple[str, ...]]: """ Return the dependencies which are not satisfied from the combined set of :attr:`build_system_requires` and :meth:`get_requires_for_build` for a given @@ -240,8 +291,19 @@ def check_dependencies(self, distribution: str, config_settings: ConfigSettingsT :param config_settings: Config settings for the build backend :returns: Set of variable-length unmet dependency tuples """ - dependencies = self.get_requires_for_build(distribution, config_settings).union(self.build_system_requires) - return {u for d in dependencies for u in check_dependency(d)} + if self._project_name is None: + self._check_dependencies_incomplete[distribution] = True + build_system_dependencies = self.check_build_system_dependencies() + requires_for_build = self.get_requires_for_build(distribution, config_settings, finalize=finalize) + dependencies = { + u for d in requires_for_build for u in check_dependency(d, project_name=self._project_name, backend=self._backend) + } + return dependencies.union(build_system_dependencies) + + def finalize_check_dependencies(self, distribution: str, config_settings: ConfigSettingsType | None = None) -> None: + if self._check_dependencies_incomplete[distribution] and self._project_name is not None: + self.check_dependencies(distribution, config_settings, finalize=True) + self._check_dependencies_incomplete[distribution] = False def prepare( self, distribution: str, output_directory: PathType, config_settings: ConfigSettingsType | None = None @@ -286,7 +348,11 @@ def build( """ self.log(f'Building {distribution}...') kwargs = {} if metadata_directory is None else {'metadata_directory': metadata_directory} - return self._call_backend(f'build_{distribution}', output_directory, config_settings, **kwargs) + basename = self._call_backend(f'build_{distribution}', output_directory, config_settings, **kwargs) + project_name = project_name_from_path(basename, distribution) + if project_name: + self._update_project_name(project_name, f'build_{distribution}') + return basename def metadata_path(self, output_directory: PathType) -> str: """ @@ -301,6 +367,9 @@ def metadata_path(self, output_directory: PathType) -> str: # prepare_metadata hook metadata = self.prepare('wheel', output_directory) if metadata is not None: + project_name = project_name_from_path(metadata, 'distinfo') + if project_name: + self._update_project_name(project_name, 'prepare_metadata_for_build_wheel') return metadata # fallback to build_wheel hook @@ -308,6 +377,7 @@ def metadata_path(self, output_directory: PathType) -> str: match = parse_wheel_filename(os.path.basename(wheel)) if not match: raise ValueError('Invalid wheel') + self._update_project_name(match['distribution'], 'build_wheel') distinfo = f"{match['distribution']}-{match['version']}.dist-info" member_prefix = f'{distinfo}/' with zipfile.ZipFile(wheel) as w: @@ -352,6 +422,16 @@ def _handle_backend(self, hook: str) -> Iterator[None]: except Exception as exception: raise BuildBackendException(exception, exc_info=sys.exc_info()) # noqa: B904 # use raise from + def _update_project_name(self, name: str, source: str) -> None: + if ( + self._project_name is not None + and self._project_name_source is not None + and packaging.utils.canonicalize_name(self._project_name) != packaging.utils.canonicalize_name(name) + ): + raise ProjectNameValidationError(self._project_name, self._project_name_source, name, source) + self._project_name = name + self._project_name_source = source + @staticmethod def log(message: str) -> None: """ @@ -373,9 +453,11 @@ def log(message: str) -> None: 'BuildSystemTableValidationError', 'BuildBackendException', 'BuildException', + 'CircularBuildDependencyError', 'ConfigSettingsType', 'FailedProcessError', 'ProjectBuilder', + 'ProjectTableValidationError', 'RunnerType', 'TypoWarning', 'check_dependency', diff --git a/src/build/__main__.py b/src/build/__main__.py index 916964b3..7269daf2 100644 --- a/src/build/__main__.py +++ b/src/build/__main__.py @@ -105,7 +105,11 @@ def _format_dep_chain(dep_chain: Sequence[str]) -> str: def _build_in_isolated_env( - srcdir: PathType, outdir: PathType, distribution: str, config_settings: ConfigSettingsType | None + srcdir: PathType, + outdir: PathType, + distribution: str, + config_settings: ConfigSettingsType | None, + skip_dependency_check: bool = False, ) -> str: with _DefaultIsolatedEnv() as env: builder = _ProjectBuilder.from_isolated_env(env, srcdir) @@ -113,7 +117,11 @@ def _build_in_isolated_env( env.install(builder.build_system_requires) # then get the extra required dependencies from the backend (which was installed in the call above :P) env.install(builder.get_requires_for_build(distribution)) - return builder.build(distribution, outdir, config_settings or {}) + build_result = builder.build(distribution, outdir, config_settings or {}) + # validate build system dependencies + if not skip_dependency_check: + builder.check_dependencies(distribution) + return build_result def _build_in_current_env( @@ -132,7 +140,10 @@ def _build_in_current_env( _cprint() _error(f'Missing dependencies:{dependencies}') - return builder.build(distribution, outdir, config_settings or {}) + build_result = builder.build(distribution, outdir, config_settings or {}) + builder.finalize_check_dependencies(distribution) + + return build_result def _build( @@ -144,7 +155,7 @@ def _build( skip_dependency_check: bool, ) -> str: if isolation: - return _build_in_isolated_env(srcdir, outdir, distribution, config_settings) + return _build_in_isolated_env(srcdir, outdir, distribution, config_settings, skip_dependency_check) else: return _build_in_current_env(srcdir, outdir, distribution, config_settings, skip_dependency_check) diff --git a/src/build/_exceptions.py b/src/build/_exceptions.py index 90a75b24..d74b5f48 100644 --- a/src/build/_exceptions.py +++ b/src/build/_exceptions.py @@ -43,6 +43,34 @@ def __str__(self) -> str: return f'Failed to validate `build-system` in pyproject.toml: {self.args[0]}' +class ProjectNameValidationError(BuildException): + """ + Exception raised when the project name is not consistent. + """ + + def __init__(self, existing: str, existing_source: str, new: str, new_source: str) -> None: + super().__init__() + self._existing = existing + self._existing_source = existing_source + self._new = new + self._new_source = new_source + + def __str__(self) -> str: + return ( + f'Failed to validate project name: `{self._new}` from `{self._new_source}` ' + f'does not match `{self._existing}` from `{self._existing_source}`' + ) + + +class ProjectTableValidationError(BuildException): + """ + Exception raised when the ``[project]`` table in pyproject.toml is invalid. + """ + + def __str__(self) -> str: + return f'Failed to validate `project` in pyproject.toml: {self.args[0]}' + + class FailedProcessError(Exception): """ Exception raised when a setup or preparation operation fails. @@ -64,6 +92,30 @@ def __str__(self) -> str: return description +class CircularBuildDependencyError(BuildException): + """ + Exception raised when a ``[build-system]`` requirement in pyproject.toml is circular. + """ + + def __init__( + self, project_name: str, ancestral_req_strings: tuple[str, ...], req_string: str, backend: str | None + ) -> None: + super().__init__() + self.project_name: str = project_name + self.ancestral_req_strings: tuple[str, ...] = ancestral_req_strings + self.req_string: str = req_string + self.backend: str | None = backend + + def __str__(self) -> str: + cycle_err_str = f'`{self.project_name}`' + if self.backend: + cycle_err_str += f' -> `{self.backend}`' + for dep in self.ancestral_req_strings: + cycle_err_str += f' -> `{dep}`' + cycle_err_str += f' -> `{self.req_string}`' + return f'Failed to validate `build-system` in pyproject.toml, dependency cycle detected: {cycle_err_str}' + + class TypoWarning(Warning): """ Warning raised when a possible typo is found. diff --git a/src/build/_util.py b/src/build/_util.py index 18168fe0..7a129930 100644 --- a/src/build/_util.py +++ b/src/build/_util.py @@ -1,10 +1,18 @@ from __future__ import annotations +import os import re import sys from collections.abc import Iterator, Set +from ._exceptions import CircularBuildDependencyError + +_DISTINFO_FOLDERNAME_REGEX = re.compile(r'(?P.+)-(?P.+)\.dist-info') + + +_SDIST_FILENAME_REGEX = re.compile(r'(?P.+)-(?P.+)\.tar.gz') + _WHEEL_FILENAME_REGEX = re.compile( r'(?P.+)-(?P.+)' @@ -13,8 +21,25 @@ ) -def check_dependency( - req_string: str, ancestral_req_strings: tuple[str, ...] = (), parent_extras: Set[str] = frozenset() +def project_name_from_path(basename: str, pathtype: str) -> str | None: + match = None + if pathtype == 'wheel': + match = _WHEEL_FILENAME_REGEX.match(os.path.basename(basename)) + elif pathtype == 'sdist': + match = _SDIST_FILENAME_REGEX.match(os.path.basename(basename)) + elif pathtype == 'distinfo': + match = _DISTINFO_FOLDERNAME_REGEX.match(os.path.basename(basename)) + if match: + return match['distribution'] + return None + + +def check_dependency( # noqa: C901 + req_string: str, + ancestral_req_strings: tuple[str, ...] = (), + parent_extras: Set[str] = frozenset(), + project_name: str | None = None, + backend: str | None = None, ) -> Iterator[tuple[str, ...]]: """ Verify that a dependency and all of its dependencies are met. @@ -24,6 +49,7 @@ def check_dependency( :yields: Unmet dependencies """ import packaging.requirements + import packaging.utils if sys.version_info >= (3, 8): import importlib.metadata as importlib_metadata @@ -48,6 +74,14 @@ def check_dependency( # dependency is satisfied. return + # Front ends SHOULD check explicitly for requirement cycles, and + # terminate the build with an informative message if one is found. + # https://www.python.org/dev/peps/pep-0517/#build-requirements + if project_name is not None and packaging.utils.canonicalize_name(req.name) == packaging.utils.canonicalize_name( + project_name + ): + raise CircularBuildDependencyError(project_name, ancestral_req_strings, req_string, backend) + try: dist = importlib_metadata.distribution(req.name) # type: ignore[no-untyped-call] except importlib_metadata.PackageNotFoundError: @@ -60,7 +94,9 @@ def check_dependency( elif dist.requires: for other_req_string in dist.requires: # yields transitive dependencies that are not satisfied. - yield from check_dependency(other_req_string, (*ancestral_req_strings, normalised_req_string), req.extras) + yield from check_dependency( + other_req_string, (*ancestral_req_strings, normalised_req_string), req.extras, project_name + ) def parse_wheel_filename(filename: str) -> re.Match[str] | None: diff --git a/tests/packages/test-circular-get-requires/backend.py b/tests/packages/test-circular-get-requires/backend.py new file mode 100644 index 00000000..069f2077 --- /dev/null +++ b/tests/packages/test-circular-get-requires/backend.py @@ -0,0 +1,17 @@ +# SPDX-License-Identifier: MIT + + +def get_requires_for_build_sdist(config_settings=None): + return ['recursive_dep'] + + +def get_requires_for_build_wheel(config_settings=None): + return ['recursive_dep'] + + +def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): + raise NotImplementedError + + +def build_sdist(sdist_directory, config_settings=None): + raise NotImplementedError diff --git a/tests/packages/test-circular-get-requires/pyproject.toml b/tests/packages/test-circular-get-requires/pyproject.toml new file mode 100644 index 00000000..9cf88653 --- /dev/null +++ b/tests/packages/test-circular-get-requires/pyproject.toml @@ -0,0 +1,9 @@ +[build-system] +requires = [] +build-backend = 'backend' +backend-path = ['.'] + +[project] +name = "recursive_unmet_dep" +version = "1.0.0" +description = "circular project" diff --git a/tests/packages/test-circular-metadata/backend.py b/tests/packages/test-circular-metadata/backend.py new file mode 100644 index 00000000..6eb2b0af --- /dev/null +++ b/tests/packages/test-circular-metadata/backend.py @@ -0,0 +1,46 @@ +# SPDX-License-Identifier: MIT + +import pathlib +import textwrap + + +def get_requires_for_build_wheel(config_settings=None): + return ['recursive_dep'] + + +def prepare_metadata_for_build_wheel(metadata_directory, config_settings=None): + metadata = { + 'project': { + 'name': 'recursive_unmet_dep', + 'version': '1.0.0', + 'description': 'circular project', + } + } + + distinfo = pathlib.Path( + metadata_directory, + '{}-{}.dist-info'.format( + metadata['project']['name'].replace('-', '-'), + metadata['project']['version'], + ), + ) + distinfo.mkdir(parents=True, exist_ok=True) + distinfo.joinpath('METADATA').write_text( + textwrap.dedent( + f''' + Metadata-Version: 2.2 + Name: {metadata['project']['name']} + Version: {metadata['project']['version']} + Summary: {metadata['project']['description']} + ''' + ).strip() + ) + return distinfo.name + + +def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): + raise NotImplementedError + + +def build_sdist(sdist_directory, config_settings=None): + raise NotImplementedError diff --git a/tests/packages/test-circular-metadata/pyproject.toml b/tests/packages/test-circular-metadata/pyproject.toml new file mode 100644 index 00000000..fa8c7fac --- /dev/null +++ b/tests/packages/test-circular-metadata/pyproject.toml @@ -0,0 +1,4 @@ +[build-system] +requires = [] +build-backend = 'backend' +backend-path = ['.'] diff --git a/tests/packages/test-circular-requirements/pyproject.toml b/tests/packages/test-circular-requirements/pyproject.toml new file mode 100644 index 00000000..7853dd23 --- /dev/null +++ b/tests/packages/test-circular-requirements/pyproject.toml @@ -0,0 +1,7 @@ +[build-system] +requires = ["recursive_dep"] + +[project] +name = "recursive_unmet_dep" +version = "1.0.0" +description = "circular project" diff --git a/tests/packages/test-circular-wheel/pyproject.toml b/tests/packages/test-circular-wheel/pyproject.toml new file mode 100644 index 00000000..7327e026 --- /dev/null +++ b/tests/packages/test-circular-wheel/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ['setuptools >= 40.8.0'] +build-backend = "setuptools.build_meta" diff --git a/tests/packages/test-circular-wheel/setup.cfg b/tests/packages/test-circular-wheel/setup.cfg new file mode 100644 index 00000000..e05b235b --- /dev/null +++ b/tests/packages/test-circular-wheel/setup.cfg @@ -0,0 +1,7 @@ +[metadata] +name = wheel +version = 1.0.0 + + +[options] +setup_requires = wheel diff --git a/tests/test_main.py b/tests/test_main.py index 456ff749..91c6555f 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -131,7 +131,7 @@ def test_build_isolated(mocker, package_test_flit): mocker.patch('build.__main__._error') install = mocker.patch('build.env.DefaultIsolatedEnv.install') - build.__main__.build_package(package_test_flit, '.', ['sdist']) + build.__main__.build_package(package_test_flit, '.', ['sdist'], skip_dependency_check=True) install.assert_any_call({'flit_core >=2,<3'}) @@ -230,12 +230,14 @@ def test_build_package_via_sdist_invalid_distribution(tmp_dir, package_test_setu '* Installing packages in isolated environment... (setuptools >= 42.0.0, wheel >= 0.36.0)', '* Getting build dependencies for sdist...', '* Building sdist...', + '* Getting build dependencies for sdist...', '* Building wheel from sdist', '* Creating venv isolated environment...', '* Installing packages in isolated environment... (setuptools >= 42.0.0, wheel >= 0.36.0)', '* Getting build dependencies for wheel...', '* Installing packages in isolated environment... (wheel)', '* Building wheel...', + '* Getting build dependencies for wheel...', 'Successfully built test_setuptools-1.0.0.tar.gz and test_setuptools-1.0.0-py2.py3-none-any.whl', ], ), @@ -258,6 +260,7 @@ def test_build_package_via_sdist_invalid_distribution(tmp_dir, package_test_setu '* Getting build dependencies for wheel...', '* Installing packages in isolated environment... (wheel)', '* Building wheel...', + '* Getting build dependencies for wheel...', 'Successfully built test_setuptools-1.0.0-py2.py3-none-any.whl', ], ), diff --git a/tests/test_projectbuilder.py b/tests/test_projectbuilder.py index f03839d3..b0f87803 100644 --- a/tests/test_projectbuilder.py +++ b/tests/test_projectbuilder.py @@ -4,6 +4,7 @@ import copy import logging import os +import pathlib import sys import textwrap @@ -18,8 +19,6 @@ else: # pragma: no cover import importlib_metadata -import pathlib - build_open_owner = 'builtins' @@ -171,6 +170,54 @@ def test_check_dependency(monkeypatch, requirement_string, expected): assert next(build.check_dependency(requirement_string), None) == expected +@pytest.mark.parametrize('distribution', ['wheel', 'sdist']) +def test_build_no_isolation_circular_get_requires(monkeypatch, package_test_circular_get_requires, distribution): + monkeypatch.setattr(importlib_metadata, 'Distribution', MockDistribution) + msg = ( + 'Failed to validate `build-system` in pyproject.toml, dependency cycle detected: `recursive_unmet_dep` -> ' + '`recursive_dep` -> `recursive_unmet_dep`' + ) + builder = build.ProjectBuilder(package_test_circular_get_requires) + with pytest.raises(build.CircularBuildDependencyError, match=msg): + builder.check_dependencies(distribution) + + +def test_build_no_isolation_circular_metadata(monkeypatch, package_test_circular_metadata, tmp_dir): + monkeypatch.setattr(importlib_metadata, 'Distribution', MockDistribution) + msg = ( + 'Failed to validate `build-system` in pyproject.toml, dependency cycle detected: `recursive_unmet_dep` -> ' + '`recursive_dep` -> `recursive_unmet_dep`' + ) + builder = build.ProjectBuilder(package_test_circular_metadata) + with pytest.raises(build.CircularBuildDependencyError, match=msg): + builder.metadata_path(tmp_dir) + builder.check_dependencies('wheel') + + +@pytest.mark.parametrize('distribution', ['wheel', 'sdist']) +def test_build_no_isolation_circular_requirements(monkeypatch, package_test_circular_requirements, distribution): + monkeypatch.setattr(importlib_metadata, 'Distribution', MockDistribution) + msg = ( + 'Failed to validate `build-system` in pyproject.toml, dependency cycle detected: `recursive_unmet_dep` -> ' + '`recursive_dep` -> `recursive_unmet_dep`' + ) + builder = build.ProjectBuilder(package_test_circular_requirements) + with pytest.raises(build.CircularBuildDependencyError, match=msg): + builder.check_dependencies(distribution) + + +@pytest.mark.parametrize('distribution', ['wheel', 'sdist']) +def test_build_no_isolation_circular_wheel(monkeypatch, package_test_circular_wheel, distribution, tmp_dir): + msg = ( + 'Failed to validate `build-system` in pyproject.toml, dependency cycle detected: `wheel` -> ' + '`setuptools.build_meta` -> `wheel`' + ) + builder = build.ProjectBuilder(package_test_circular_wheel) + with pytest.raises(build.CircularBuildDependencyError, match=msg): + builder.build(distribution, tmp_dir) + builder.check_dependencies(distribution) + + def test_bad_project(package_test_no_project): # Passing a nonexistent project directory with pytest.raises(build.BuildException): @@ -395,6 +442,7 @@ def test_build_with_dep_on_console_script(tmp_path, demo_pkg_inline, capfd, mock backend-path = ["."] [project] + name = "inline_test" description = "Factory ⸻ A code generator 🏭" authors = [{name = "Łukasz Langa"}] ''' @@ -417,7 +465,7 @@ def test_build_with_dep_on_console_script(tmp_path, demo_pkg_inline, capfd, mock from build.__main__ import main with pytest.raises(SystemExit): - main(['--wheel', '--outdir', str(tmp_path / 'dist'), str(tmp_path)]) + main(['--wheel', '--skip-dependency-check', '--outdir', str(tmp_path / 'dist'), str(tmp_path)]) out, err = capfd.readouterr() lines = [line[3:] for line in out.splitlines() if line.startswith('BB ')] # filter for our markers @@ -619,3 +667,21 @@ def test_parse_valid_build_system_table_type(pyproject_toml, parse_output): 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) + + +@pytest.mark.parametrize( + ('pyproject_toml', 'error_message'), + [ + ( + {'build-system': {'requires': ['foo']}, 'project': {}}, + '`project` must have a `name` field', + ), + ( + {'build-system': {'requires': ['foo']}, 'project': {'name': 1}}, + '`name` field in `project` must be a string', + ), + ], +) +def test_parse_invalid_project_name(pyproject_toml, error_message): + with pytest.raises(build.ProjectTableValidationError, match=error_message): + build._parse_project_name(pyproject_toml)