Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

build: add circular dependency checker for build requirements #593

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 93 additions & 10 deletions src/build/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,21 @@
from collections.abc import Iterator
from typing import Any, Callable, Mapping, Optional, Sequence, TypeVar, Union

import packaging.utils
import pyproject_hooks

from . import env
from ._exceptions import (
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
Expand Down Expand Up @@ -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:
layday marked this conversation as resolved.
Show resolved Hide resolved
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 {})})
Expand Down Expand Up @@ -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,
Expand All @@ -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:
"""
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
"""
Expand All @@ -301,13 +367,17 @@ 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
wheel = self.build('wheel', output_directory)
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:
Expand Down Expand Up @@ -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:
"""
Expand All @@ -373,9 +453,12 @@ def log(message: str) -> None:
'BuildSystemTableValidationError',
'BuildBackendException',
'BuildException',
'CircularBuildDependencyError',
'ConfigSettingsType',
'FailedProcessError',
'ProjectBuilder',
'ProjectNameValidationError',
'ProjectTableValidationError',
'RunnerType',
'TypoWarning',
'check_dependency',
Expand Down
19 changes: 15 additions & 4 deletions src/build/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,15 +105,23 @@ 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)
# first install the build dependencies
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(
Expand All @@ -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(
Expand All @@ -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)

Expand Down
52 changes: 52 additions & 0 deletions src/build/_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
Loading