Skip to content

Commit 232ba32

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 cb91293 commit 232ba32

File tree

4 files changed

+126
-12
lines changed

4 files changed

+126
-12
lines changed

src/build/__init__.py

Lines changed: 80 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@
5454
_ExcInfoType = Union[Tuple[Type[BaseException], BaseException, types.TracebackType], Tuple[None, None, None]]
5555

5656

57+
_SDIST_NAME_REGEX = re.compile(r'(?P<distribution>.+)-(?P<version>.+)\.tar.gz')
58+
59+
5760
_WHEEL_NAME_REGEX = re.compile(
5861
r'(?P<distribution>.+)-(?P<version>.+)'
5962
r'(-(?P<build_tag>.+))?-(?P<python_tag>.+)'
@@ -104,6 +107,19 @@ def __str__(self) -> str:
104107
return f'Failed to validate `build-system` in pyproject.toml: {self.args[0]}'
105108

106109

110+
class CircularBuildSystemDependencyError(BuildException):
111+
"""
112+
Exception raised when a ``[build-system]`` requirement in pyproject.toml is circular.
113+
"""
114+
115+
def __str__(self) -> str:
116+
cycle_deps = self.args[0]
117+
cycle_err_str = f'`{cycle_deps[0]}`'
118+
for dep in cycle_deps[1:]:
119+
cycle_err_str += f' -> `{dep}`'
120+
return f'Failed to validate `build-system` in pyproject.toml, dependency cycle detected: {cycle_err_str}'
121+
122+
107123
class TypoWarning(Warning):
108124
"""
109125
Warning raised when a potential typo is found
@@ -131,8 +147,16 @@ def _validate_source_directory(srcdir: PathType) -> None:
131147
raise BuildException(f'Source {srcdir} does not appear to be a Python project: no pyproject.toml or setup.py')
132148

133149

150+
# https://www.python.org/dev/peps/pep-0503/#normalized-names
151+
def _normalize(name: str) -> str:
152+
return re.sub(r'[-_.]+', '-', name).lower()
153+
154+
134155
def check_dependency(
135-
req_string: str, ancestral_req_strings: Tuple[str, ...] = (), parent_extras: AbstractSet[str] = frozenset()
156+
req_string: str,
157+
ancestral_req_strings: Tuple[str, ...] = (),
158+
parent_extras: AbstractSet[str] = frozenset(),
159+
project_name: Optional[str] = None,
136160
) -> Iterator[Tuple[str, ...]]:
137161
"""
138162
Verify that a dependency and all of its dependencies are met.
@@ -159,6 +183,12 @@ def check_dependency(
159183
# dependency is satisfied.
160184
return
161185

186+
# Front ends SHOULD check explicitly for requirement cycles, and
187+
# terminate the build with an informative message if one is found.
188+
# https://www.python.org/dev/peps/pep-0517/#build-requirements
189+
if project_name is not None and _normalize(req.name) == _normalize(project_name):
190+
raise CircularBuildSystemDependencyError((project_name,) + ancestral_req_strings + (req_string,))
191+
162192
try:
163193
dist = importlib_metadata.distribution(req.name) # type: ignore[no-untyped-call]
164194
except importlib_metadata.PackageNotFoundError:
@@ -171,7 +201,7 @@ def check_dependency(
171201
elif dist.requires:
172202
for other_req_string in dist.requires:
173203
# yields transitive dependencies that are not satisfied.
174-
yield from check_dependency(other_req_string, ancestral_req_strings + (req_string,), req.extras)
204+
yield from check_dependency(other_req_string, ancestral_req_strings + (req_string,), req.extras, project_name)
175205

176206

177207
def _find_typo(dictionary: Mapping[str, str], expected: str) -> None:
@@ -222,6 +252,23 @@ def _parse_build_system_table(pyproject_toml: Mapping[str, Any]) -> Dict[str, An
222252
return build_system_table
223253

224254

255+
def _parse_project_name(pyproject_toml: Mapping[str, Any]) -> Optional[str]:
256+
if 'project' not in pyproject_toml:
257+
return None
258+
259+
project_table = dict(pyproject_toml['project'])
260+
261+
# If [project] is present, it must have a ``name`` field (per PEP 621)
262+
if 'name' not in project_table:
263+
return None
264+
265+
project_name = project_table['name']
266+
if not isinstance(project_name, str):
267+
return None
268+
269+
return project_name
270+
271+
225272
class ProjectBuilder:
226273
"""
227274
The PEP 517 consumer API.
@@ -267,10 +314,14 @@ def __init__(
267314
except TOMLDecodeError as e:
268315
raise BuildException(f'Failed to parse {spec_file}: {e} ')
269316

317+
self.project_name: Optional[str] = _parse_project_name(spec)
270318
self._build_system = _parse_build_system_table(spec)
271319
self._backend = self._build_system['build-backend']
272320
self._scripts_dir = scripts_dir
273321
self._hook_runner = runner
322+
self._in_tree_build = False
323+
if 'backend-path' in self._build_system:
324+
self._in_tree_build = True
274325
self._hook = pep517.wrappers.Pep517HookCaller(
275326
self.srcdir,
276327
self._backend,
@@ -341,6 +392,17 @@ def get_requires_for_build(self, distribution: str, config_settings: Optional[Co
341392
with self._handle_backend(hook_name):
342393
return set(get_requires(config_settings))
343394

395+
def check_build_dependencies(self) -> Set[Tuple[str, ...]]:
396+
"""
397+
Return the dependencies which are not satisfied from
398+
:attr:`build_system_requires`
399+
400+
:returns: Set of variable-length unmet dependency tuples
401+
"""
402+
if self._in_tree_build:
403+
return set()
404+
return {u for d in self.build_system_requires for u in check_dependency(d, project_name=self.project_name)}
405+
344406
def check_dependencies(
345407
self, distribution: str, config_settings: Optional[ConfigSettingsType] = None
346408
) -> Set[Tuple[str, ...]]:
@@ -353,8 +415,9 @@ def check_dependencies(
353415
:param config_settings: Config settings for the build backend
354416
:returns: Set of variable-length unmet dependency tuples
355417
"""
356-
dependencies = self.get_requires_for_build(distribution, config_settings).union(self.build_system_requires)
357-
return {u for d in dependencies for u in check_dependency(d)}
418+
build_system_dependencies = self.check_build_dependencies()
419+
dependencies = {u for d in self.get_requires_for_build(distribution, config_settings) for u in check_dependency(d)}
420+
return dependencies.union(build_system_dependencies)
358421

359422
def prepare(
360423
self, distribution: str, output_directory: PathType, config_settings: Optional[ConfigSettingsType] = None
@@ -399,7 +462,15 @@ def build(
399462
"""
400463
self.log(f'Building {distribution}...')
401464
kwargs = {} if metadata_directory is None else {'metadata_directory': metadata_directory}
402-
return self._call_backend(f'build_{distribution}', output_directory, config_settings, **kwargs)
465+
basename = self._call_backend(f'build_{distribution}', output_directory, config_settings, **kwargs)
466+
match = None
467+
if distribution == 'wheel':
468+
match = _WHEEL_NAME_REGEX.match(os.path.basename(basename))
469+
elif distribution == 'sdist':
470+
match = _SDIST_NAME_REGEX.match(os.path.basename(basename))
471+
if match:
472+
self.project_name = match['distribution']
473+
return basename
403474

404475
def metadata_path(self, output_directory: PathType) -> str:
405476
"""
@@ -413,13 +484,17 @@ def metadata_path(self, output_directory: PathType) -> str:
413484
# prepare_metadata hook
414485
metadata = self.prepare('wheel', output_directory)
415486
if metadata is not None:
487+
match = _WHEEL_NAME_REGEX.match(os.path.basename(metadata))
488+
if match:
489+
self.project_name = match['distribution']
416490
return metadata
417491

418492
# fallback to build_wheel hook
419493
wheel = self.build('wheel', output_directory)
420494
match = _WHEEL_NAME_REGEX.match(os.path.basename(wheel))
421495
if not match:
422496
raise ValueError('Invalid wheel')
497+
self.project_name = match['distribution']
423498
distinfo = f"{match['distribution']}-{match['version']}.dist-info"
424499
member_prefix = f'{distinfo}/'
425500
with zipfile.ZipFile(wheel) as w:

src/build/__main__.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,16 +99,29 @@ def _format_dep_chain(dep_chain: Sequence[str]) -> str:
9999

100100

101101
def _build_in_isolated_env(
102-
builder: ProjectBuilder, outdir: PathType, distribution: str, config_settings: Optional[ConfigSettingsType]
102+
builder: ProjectBuilder,
103+
outdir: PathType,
104+
distribution: str,
105+
config_settings: Optional[ConfigSettingsType],
106+
skip_dependency_check: bool = False,
103107
) -> str:
104108
with _IsolatedEnvBuilder() as env:
105109
builder.python_executable = env.executable
106110
builder.scripts_dir = env.scripts_dir
107111
# first install the build dependencies
108112
env.install(builder.build_system_requires)
113+
# validate build system dependencies
114+
revalidate = False
115+
if not skip_dependency_check:
116+
builder.check_build_dependencies()
117+
if builder.project_name is None:
118+
revalidate = True
109119
# then get the extra required dependencies from the backend (which was installed in the call above :P)
110120
env.install(builder.get_requires_for_build(distribution))
111-
return builder.build(distribution, outdir, config_settings or {})
121+
build_result = builder.build(distribution, outdir, config_settings or {})
122+
if revalidate and builder.project_name is not None:
123+
builder.check_build_dependencies()
124+
return build_result
112125

113126

114127
def _build_in_current_env(
@@ -118,14 +131,22 @@ def _build_in_current_env(
118131
config_settings: Optional[ConfigSettingsType],
119132
skip_dependency_check: bool = False,
120133
) -> str:
134+
revalidate = False
121135
if not skip_dependency_check:
122136
missing = builder.check_dependencies(distribution)
123137
if missing:
124138
dependencies = ''.join('\n\t' + dep for deps in missing for dep in (deps[0], _format_dep_chain(deps[1:])) if dep)
125139
print()
126140
_error(f'Missing dependencies:{dependencies}')
141+
elif builder.project_name is None:
142+
revalidate = True
143+
144+
build_result = builder.build(distribution, outdir, config_settings or {})
145+
146+
if revalidate and builder.project_name is not None:
147+
builder.check_build_dependencies()
127148

128-
return builder.build(distribution, outdir, config_settings or {})
149+
return build_result
129150

130151

131152
def _build(
@@ -137,7 +158,7 @@ def _build(
137158
skip_dependency_check: bool,
138159
) -> str:
139160
if isolation:
140-
return _build_in_isolated_env(builder, outdir, distribution, config_settings)
161+
return _build_in_isolated_env(builder, outdir, distribution, config_settings, skip_dependency_check)
141162
else:
142163
return _build_in_current_env(builder, outdir, distribution, config_settings, skip_dependency_check)
143164

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[build-system]
2+
requires = ["recursive_dep"]
3+
4+
[project]
5+
name = "recursive_unmet_dep"
6+
version = "1.0.0"
7+
description = "circular project"

tests/test_projectbuilder.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import importlib
66
import logging
77
import os
8+
import pathlib
89
import sys
910
import textwrap
1011

@@ -19,8 +20,6 @@
1920
else: # pragma: no cover
2021
import importlib_metadata
2122

22-
import pathlib
23-
2423

2524
build_open_owner = 'builtins'
2625

@@ -133,6 +132,18 @@ def test_check_dependency(monkeypatch, requirement_string, expected):
133132
assert next(build.check_dependency(requirement_string), None) == expected
134133

135134

135+
@pytest.mark.parametrize('distribution', ['wheel', 'sdist'])
136+
def test_build_no_isolation_circular_requirements(monkeypatch, package_test_circular_requirements, distribution):
137+
monkeypatch.setattr(importlib_metadata, 'Distribution', MockDistribution)
138+
msg = (
139+
'Failed to validate `build-system` in pyproject.toml, dependency cycle detected: `recursive_unmet_dep` -> '
140+
'`recursive_dep` -> `recursive_unmet_dep`'
141+
)
142+
builder = build.ProjectBuilder(package_test_circular_requirements)
143+
with pytest.raises(build.CircularBuildSystemDependencyError, match=msg):
144+
builder.check_build_dependencies()
145+
146+
136147
def test_bad_project(package_test_no_project):
137148
# Passing a nonexistent project directory
138149
with pytest.raises(build.BuildException):
@@ -570,7 +581,7 @@ def test_log(mocker, caplog, package_test_flit):
570581
('INFO', 'something'),
571582
]
572583
if sys.version_info >= (3, 8): # stacklevel
573-
assert caplog.records[-1].lineno == 562
584+
assert caplog.records[-1].lineno == test_log.__code__.co_firstlineno + 11
574585

575586

576587
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)