Skip to content

Commit 3e659e0

Browse files
committed
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
1 parent dd21316 commit 3e659e0

File tree

13 files changed

+364
-21
lines changed

13 files changed

+364
-21
lines changed

src/build/__init__.py

Lines changed: 92 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,21 @@
2121
from collections.abc import Iterator
2222
from typing import Any, Callable, Mapping, Optional, Sequence, TypeVar, Union
2323

24+
import packaging.utils
2425
import pyproject_hooks
2526

2627
from . import env
2728
from ._exceptions import (
2829
BuildBackendException,
2930
BuildException,
3031
BuildSystemTableValidationError,
32+
CircularBuildDependencyError,
3133
FailedProcessError,
34+
ProjectTableValidationError,
3235
TypoWarning,
36+
ProjectNameValidationError,
3337
)
34-
from ._util import check_dependency, parse_wheel_filename
35-
38+
from ._util import check_dependency, parse_wheel_filename, project_name_from_path
3639

3740
if sys.version_info >= (3, 11):
3841
import tomllib
@@ -126,6 +129,23 @@ def _parse_build_system_table(pyproject_toml: Mapping[str, Any]) -> Mapping[str,
126129
return build_system_table
127130

128131

132+
def _parse_project_name(pyproject_toml: Mapping[str, Any]) -> str | None:
133+
if 'project' not in pyproject_toml:
134+
return None
135+
136+
project_table = dict(pyproject_toml['project'])
137+
138+
# If [project] is present, it must have a ``name`` field (per PEP 621)
139+
if 'name' not in project_table:
140+
raise ProjectTableValidationError('`project` must have a `name` field')
141+
142+
project_name = project_table['name']
143+
if not isinstance(project_name, str):
144+
raise ProjectTableValidationError('`name` field in `project` must be a string')
145+
146+
return project_name
147+
148+
129149
def _wrap_subprocess_runner(runner: RunnerType, env: env.IsolatedEnv) -> RunnerType:
130150
def _invoke_wrapped_runner(cmd: Sequence[str], cwd: str | None, extra_environ: Mapping[str, str] | None) -> None:
131151
runner(cmd, cwd, {**(env.make_extra_environ() or {}), **(extra_environ or {})})
@@ -168,10 +188,18 @@ def __init__(
168188
self._runner = runner
169189

170190
pyproject_toml_path = os.path.join(source_dir, 'pyproject.toml')
171-
self._build_system = _parse_build_system_table(_read_pyproject_toml(pyproject_toml_path))
172-
191+
pyproject_toml = _read_pyproject_toml(pyproject_toml_path)
192+
self._build_system = _parse_build_system_table(pyproject_toml)
193+
194+
self._project_name: str | None = None
195+
self._project_name_source: str | None = None
196+
project_name = _parse_project_name(pyproject_toml)
197+
if project_name:
198+
self._update_project_name(project_name, 'pyproject.toml [project] table')
173199
self._backend = self._build_system['build-backend']
174200

201+
self._check_dependencies_incomplete: dict[str, bool] = {'wheel': False, 'sdist': False}
202+
175203
self._hook = pyproject_hooks.BuildBackendHookCaller(
176204
self._source_dir,
177205
self._backend,
@@ -198,6 +226,15 @@ def source_dir(self) -> str:
198226
"""Project source directory."""
199227
return self._source_dir
200228

229+
@property
230+
def project_name(self) -> str | None:
231+
"""
232+
The canonicalized project name.
233+
"""
234+
if self._project_name is not None:
235+
return packaging.utils.canonicalize_name(self._project_name)
236+
return None
237+
201238
@property
202239
def python_executable(self) -> str:
203240
"""
@@ -214,7 +251,9 @@ def build_system_requires(self) -> set[str]:
214251
"""
215252
return set(self._build_system['requires'])
216253

217-
def get_requires_for_build(self, distribution: str, config_settings: ConfigSettingsType | None = None) -> set[str]:
254+
def get_requires_for_build(
255+
self, distribution: str, config_settings: ConfigSettingsType | None = None, finalize: bool = False
256+
) -> set[str]:
218257
"""
219258
Return the dependencies defined by the backend in addition to
220259
:attr:`build_system_requires` for a given distribution.
@@ -223,14 +262,26 @@ def get_requires_for_build(self, distribution: str, config_settings: ConfigSetti
223262
(``sdist`` or ``wheel``)
224263
:param config_settings: Config settings for the build backend
225264
"""
226-
self.log(f'Getting build dependencies for {distribution}...')
265+
if not finalize:
266+
self.log(f'Getting build dependencies for {distribution}...')
227267
hook_name = f'get_requires_for_build_{distribution}'
228268
get_requires = getattr(self._hook, hook_name)
229269

230270
with self._handle_backend(hook_name):
231271
return set(get_requires(config_settings))
232272

233-
def check_dependencies(self, distribution: str, config_settings: ConfigSettingsType | None = None) -> set[tuple[str, ...]]:
273+
def check_build_system_dependencies(self) -> set[tuple[str, ...]]:
274+
"""
275+
Return the dependencies which are not satisfied from
276+
:attr:`build_system_requires`
277+
278+
:returns: Set of variable-length unmet dependency tuples
279+
"""
280+
return {u for d in self.build_system_requires for u in check_dependency(d, project_name=self._project_name)}
281+
282+
def check_dependencies(
283+
self, distribution: str, config_settings: ConfigSettingsType | None = None, finalize: bool = False
284+
) -> set[tuple[str, ...]]:
234285
"""
235286
Return the dependencies which are not satisfied from the combined set of
236287
: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
240291
:param config_settings: Config settings for the build backend
241292
:returns: Set of variable-length unmet dependency tuples
242293
"""
243-
dependencies = self.get_requires_for_build(distribution, config_settings).union(self.build_system_requires)
244-
return {u for d in dependencies for u in check_dependency(d)}
294+
if self._project_name is None:
295+
self._check_dependencies_incomplete[distribution] = True
296+
build_system_dependencies = self.check_build_system_dependencies()
297+
requires_for_build = self.get_requires_for_build(distribution, config_settings, finalize=finalize)
298+
dependencies = {
299+
u for d in requires_for_build for u in check_dependency(d, project_name=self._project_name, backend=self._backend)
300+
}
301+
return dependencies.union(build_system_dependencies)
302+
303+
def finalize_check_dependencies(self, distribution: str, config_settings: ConfigSettingsType | None = None) -> None:
304+
if self._check_dependencies_incomplete[distribution] and self._project_name is not None:
305+
self.check_dependencies(distribution, config_settings, finalize=True)
306+
self._check_dependencies_incomplete[distribution] = False
245307

246308
def prepare(
247309
self, distribution: str, output_directory: PathType, config_settings: ConfigSettingsType | None = None
@@ -286,7 +348,11 @@ def build(
286348
"""
287349
self.log(f'Building {distribution}...')
288350
kwargs = {} if metadata_directory is None else {'metadata_directory': metadata_directory}
289-
return self._call_backend(f'build_{distribution}', output_directory, config_settings, **kwargs)
351+
basename = self._call_backend(f'build_{distribution}', output_directory, config_settings, **kwargs)
352+
project_name = project_name_from_path(basename, distribution)
353+
if project_name:
354+
self._update_project_name(project_name, f'build_{distribution}')
355+
return basename
290356

291357
def metadata_path(self, output_directory: PathType) -> str:
292358
"""
@@ -301,13 +367,17 @@ def metadata_path(self, output_directory: PathType) -> str:
301367
# prepare_metadata hook
302368
metadata = self.prepare('wheel', output_directory)
303369
if metadata is not None:
370+
project_name = project_name_from_path(metadata, 'distinfo')
371+
if project_name:
372+
self._update_project_name(project_name, 'prepare_metadata_for_build_wheel')
304373
return metadata
305374

306375
# fallback to build_wheel hook
307376
wheel = self.build('wheel', output_directory)
308377
match = parse_wheel_filename(os.path.basename(wheel))
309378
if not match:
310379
raise ValueError('Invalid wheel')
380+
self._update_project_name(match['distribution'], 'build_wheel')
311381
distinfo = f"{match['distribution']}-{match['version']}.dist-info"
312382
member_prefix = f'{distinfo}/'
313383
with zipfile.ZipFile(wheel) as w:
@@ -352,6 +422,16 @@ def _handle_backend(self, hook: str) -> Iterator[None]:
352422
except Exception as exception:
353423
raise BuildBackendException(exception, exc_info=sys.exc_info()) # noqa: B904 # use raise from
354424

425+
def _update_project_name(self, name: str, source: str) -> None:
426+
if (
427+
self._project_name is not None
428+
and self._project_name_source is not None
429+
and packaging.utils.canonicalize_name(self._project_name) != packaging.utils.canonicalize_name(name)
430+
):
431+
raise ProjectNameValidationError(self._project_name, self._project_name_source, name, source)
432+
self._project_name = name
433+
self._project_name_source = source
434+
355435
@staticmethod
356436
def log(message: str) -> None:
357437
"""
@@ -373,9 +453,11 @@ def log(message: str) -> None:
373453
'BuildSystemTableValidationError',
374454
'BuildBackendException',
375455
'BuildException',
456+
'CircularBuildDependencyError',
376457
'ConfigSettingsType',
377458
'FailedProcessError',
378459
'ProjectBuilder',
460+
'ProjectTableValidationError',
379461
'RunnerType',
380462
'TypoWarning',
381463
'check_dependency',

src/build/__main__.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,15 +105,23 @@ def _format_dep_chain(dep_chain: Sequence[str]) -> str:
105105

106106

107107
def _build_in_isolated_env(
108-
srcdir: PathType, outdir: PathType, distribution: str, config_settings: ConfigSettingsType | None
108+
srcdir: PathType,
109+
outdir: PathType,
110+
distribution: str,
111+
config_settings: ConfigSettingsType | None,
112+
skip_dependency_check: bool = False,
109113
) -> str:
110114
with _DefaultIsolatedEnv() as env:
111115
builder = _ProjectBuilder.from_isolated_env(env, srcdir)
112116
# first install the build dependencies
113117
env.install(builder.build_system_requires)
114118
# then get the extra required dependencies from the backend (which was installed in the call above :P)
115119
env.install(builder.get_requires_for_build(distribution))
116-
return builder.build(distribution, outdir, config_settings or {})
120+
build_result = builder.build(distribution, outdir, config_settings or {})
121+
# validate build system dependencies
122+
if not skip_dependency_check:
123+
builder.check_dependencies(distribution)
124+
return build_result
117125

118126

119127
def _build_in_current_env(
@@ -132,7 +140,10 @@ def _build_in_current_env(
132140
_cprint()
133141
_error(f'Missing dependencies:{dependencies}')
134142

135-
return builder.build(distribution, outdir, config_settings or {})
143+
build_result = builder.build(distribution, outdir, config_settings or {})
144+
builder.finalize_check_dependencies(distribution)
145+
146+
return build_result
136147

137148

138149
def _build(
@@ -144,7 +155,7 @@ def _build(
144155
skip_dependency_check: bool,
145156
) -> str:
146157
if isolation:
147-
return _build_in_isolated_env(srcdir, outdir, distribution, config_settings)
158+
return _build_in_isolated_env(srcdir, outdir, distribution, config_settings, skip_dependency_check)
148159
else:
149160
return _build_in_current_env(srcdir, outdir, distribution, config_settings, skip_dependency_check)
150161

src/build/_exceptions.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,34 @@ def __str__(self) -> str:
4343
return f'Failed to validate `build-system` in pyproject.toml: {self.args[0]}'
4444

4545

46+
class ProjectNameValidationError(BuildException):
47+
"""
48+
Exception raised when the project name is not consistent.
49+
"""
50+
51+
def __init__(self, existing: str, existing_source: str, new: str, new_source: str) -> None:
52+
super().__init__()
53+
self._existing = existing
54+
self._existing_source = existing_source
55+
self._new = new
56+
self._new_source = new_source
57+
58+
def __str__(self) -> str:
59+
return (
60+
f'Failed to validate project name: `{self._new}` from `{self._new_source}` '
61+
f'does not match `{self._existing}` from `{self._existing_source}`'
62+
)
63+
64+
65+
class ProjectTableValidationError(BuildException):
66+
"""
67+
Exception raised when the ``[project]`` table in pyproject.toml is invalid.
68+
"""
69+
70+
def __str__(self) -> str:
71+
return f'Failed to validate `project` in pyproject.toml: {self.args[0]}'
72+
73+
4674
class FailedProcessError(Exception):
4775
"""
4876
Exception raised when a setup or preparation operation fails.
@@ -64,6 +92,30 @@ def __str__(self) -> str:
6492
return description
6593

6694

95+
class CircularBuildDependencyError(BuildException):
96+
"""
97+
Exception raised when a ``[build-system]`` requirement in pyproject.toml is circular.
98+
"""
99+
100+
def __init__(
101+
self, project_name: str, ancestral_req_strings: tuple[str, ...], req_string: str, backend: str | None
102+
) -> None:
103+
super().__init__()
104+
self.project_name: str = project_name
105+
self.ancestral_req_strings: tuple[str, ...] = ancestral_req_strings
106+
self.req_string: str = req_string
107+
self.backend: str | None = backend
108+
109+
def __str__(self) -> str:
110+
cycle_err_str = f'`{self.project_name}`'
111+
if self.backend:
112+
cycle_err_str += f' -> `{self.backend}`'
113+
for dep in self.ancestral_req_strings:
114+
cycle_err_str += f' -> `{dep}`'
115+
cycle_err_str += f' -> `{self.req_string}`'
116+
return f'Failed to validate `build-system` in pyproject.toml, dependency cycle detected: {cycle_err_str}'
117+
118+
67119
class TypoWarning(Warning):
68120
"""
69121
Warning raised when a possible typo is found.

0 commit comments

Comments
 (0)