Skip to content

Commit

Permalink
build: add circular dependency checker for build requirements
Browse files Browse the repository at this point in the history
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
  • Loading branch information
jameshilliard committed Mar 29, 2023
1 parent dd21316 commit 3e659e0
Show file tree
Hide file tree
Showing 13 changed files with 364 additions and 21 deletions.
102 changes: 92 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:
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,11 @@ def log(message: str) -> None:
'BuildSystemTableValidationError',
'BuildBackendException',
'BuildException',
'CircularBuildDependencyError',
'ConfigSettingsType',
'FailedProcessError',
'ProjectBuilder',
'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

0 comments on commit 3e659e0

Please sign in to comment.