Skip to content

Commit 4ec3f30

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 4ec3f30

File tree

7 files changed

+98
-7
lines changed

7 files changed

+98
-7
lines changed

src/build/__init__.py

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,28 @@ def __str__(self) -> str:
104104
return f'Failed to validate `build-system` in pyproject.toml: {self.args[0]}'
105105

106106

107+
class CircularBuildSystemDependencyError(BuildException):
108+
"""
109+
Exception raised when a ``[build-system]`` requirement in pyproject.toml is circular.
110+
"""
111+
112+
def __str__(self) -> str:
113+
cycle_deps = self.args[0]
114+
cycle_err_str = f'`{cycle_deps[0]}`'
115+
for dep in cycle_deps[1:]:
116+
cycle_err_str += f' -> `{dep}`'
117+
return f'Failed to validate `build-system` in pyproject.toml, dependency cycle detected: {cycle_err_str}'
118+
119+
120+
class ProjectTableValidationError(BuildException):
121+
"""
122+
Exception raised when the ``[project]`` table in pyproject.toml is invalid.
123+
"""
124+
125+
def __str__(self) -> str:
126+
return f'Failed to validate `project` in pyproject.toml: {self.args[0]}'
127+
128+
107129
class TypoWarning(Warning):
108130
"""
109131
Warning raised when a potential typo is found
@@ -132,7 +154,10 @@ def _validate_source_directory(srcdir: PathType) -> None:
132154

133155

134156
def check_dependency(
135-
req_string: str, ancestral_req_strings: Tuple[str, ...] = (), parent_extras: AbstractSet[str] = frozenset()
157+
req_string: str,
158+
ancestral_req_strings: Tuple[str, ...] = (),
159+
parent_extras: AbstractSet[str] = frozenset(),
160+
project_name: Optional[str] = None,
136161
) -> Iterator[Tuple[str, ...]]:
137162
"""
138163
Verify that a dependency and all of its dependencies are met.
@@ -150,6 +175,12 @@ def check_dependency(
150175

151176
req = packaging.requirements.Requirement(req_string)
152177

178+
# Front ends SHOULD check explicitly for requirement cycles, and
179+
# terminate the build with an informative message if one is found.
180+
# https://www.python.org/dev/peps/pep-0517/#build-requirements
181+
if project_name is not None and req.name == project_name:
182+
raise CircularBuildSystemDependencyError(ancestral_req_strings + (req_string,))
183+
153184
if req.marker:
154185
extras = frozenset(('',)).union(parent_extras)
155186
# a requirement can have multiple extras but ``evaluate`` can
@@ -171,7 +202,7 @@ def check_dependency(
171202
elif dist.requires:
172203
for other_req_string in dist.requires:
173204
# yields transitive dependencies that are not satisfied.
174-
yield from check_dependency(other_req_string, ancestral_req_strings + (req_string,), req.extras)
205+
yield from check_dependency(other_req_string, ancestral_req_strings + (req_string,), req.extras, project_name)
175206

176207

177208
def _find_typo(dictionary: Mapping[str, str], expected: str) -> None:
@@ -222,6 +253,23 @@ def _parse_build_system_table(pyproject_toml: Mapping[str, Any]) -> Dict[str, An
222253
return build_system_table
223254

224255

256+
def _parse_project_name(pyproject_toml: Mapping[str, Any]) -> Optional[str]:
257+
if 'project' not in pyproject_toml:
258+
return None
259+
260+
project_table = dict(pyproject_toml['project'])
261+
262+
# If [project] is present, it must have a ``name`` field (per PEP 621)
263+
if 'name' not in project_table:
264+
raise ProjectTableValidationError('`name` is a required property')
265+
266+
project_name = project_table['name']
267+
if not isinstance(project_name, str):
268+
raise ProjectTableValidationError('`name` must be a string')
269+
270+
return project_name
271+
272+
225273
class ProjectBuilder:
226274
"""
227275
The PEP 517 consumer API.
@@ -268,6 +316,7 @@ def __init__(
268316
raise BuildException(f'Failed to parse {spec_file}: {e} ')
269317

270318
self._build_system = _parse_build_system_table(spec)
319+
self._project_name = _parse_project_name(spec)
271320
self._backend = self._build_system['build-backend']
272321
self._scripts_dir = scripts_dir
273322
self._hook_runner = runner
@@ -341,6 +390,15 @@ def get_requires_for_build(self, distribution: str, config_settings: Optional[Co
341390
with self._handle_backend(hook_name):
342391
return set(get_requires(config_settings))
343392

393+
def check_build_dependencies(self) -> Set[Tuple[str, ...]]:
394+
"""
395+
Return the dependencies which are not satisfied from
396+
:attr:`build_system_requires`
397+
398+
:returns: Set of variable-length unmet dependency tuples
399+
"""
400+
return {u for d in self.build_system_requires for u in check_dependency(d, project_name=self._project_name)}
401+
344402
def check_dependencies(
345403
self, distribution: str, config_settings: Optional[ConfigSettingsType] = None
346404
) -> Set[Tuple[str, ...]]:
@@ -353,8 +411,9 @@ def check_dependencies(
353411
:param config_settings: Config settings for the build backend
354412
:returns: Set of variable-length unmet dependency tuples
355413
"""
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)}
414+
build_system_dependencies = self.check_build_dependencies()
415+
dependencies = {u for d in self.get_requires_for_build(distribution, config_settings) for u in check_dependency(d)}
416+
return dependencies.union(build_system_dependencies)
358417

359418
def prepare(
360419
self, distribution: str, output_directory: PathType, config_settings: Optional[ConfigSettingsType] = None

src/build/__main__.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,13 +99,20 @@ 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+
if not skip_dependency_check:
115+
builder.check_build_dependencies()
109116
# then get the extra required dependencies from the backend (which was installed in the call above :P)
110117
env.install(builder.get_requires_for_build(distribution))
111118
return builder.build(distribution, outdir, config_settings or {})
@@ -137,7 +144,7 @@ def _build(
137144
skip_dependency_check: bool,
138145
) -> str:
139146
if isolation:
140-
return _build_in_isolated_env(builder, outdir, distribution, config_settings)
147+
return _build_in_isolated_env(builder, outdir, distribution, config_settings, skip_dependency_check)
141148
else:
142149
return _build_in_current_env(builder, outdir, distribution, config_settings, skip_dependency_check)
143150

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[build-system]
2+
requires = ["flit_core==3.5.1"]
3+
build-backend = "flit_core.buildapi"
4+
5+
[project]
6+
name = "tomli"
7+
version = "1.2.2"
8+
description = "A lil' TOML parser"

tests/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
flit_core==3.5.1

tests/test_integration.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ def test_build(monkeypatch, project, args, call, tmp_path):
123123

124124

125125
def test_isolation(tmp_dir, package_test_flit, mocker):
126+
if 'flit_core ' in sys.modules:
127+
del sys.modules["flit_core"]
126128
try:
127129
import flit_core # noqa: F401
128130
except ModuleNotFoundError:

tests/test_main.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,20 @@ def test_build_no_isolation_check_deps_empty(mocker, package_test_flit):
147147
build_cmd.assert_called_with('sdist', '.', {})
148148

149149

150+
@pytest.mark.parametrize('distribution', ['wheel', 'sdist'])
151+
def test_build_no_isolation_circular_requirements(package_test_circular_requirements, distribution):
152+
msg = 'Failed to validate `build-system` in pyproject.toml, dependency cycle detected: `flit_core==3.5.1` -> `tomli`'
153+
with pytest.raises(build.CircularBuildSystemDependencyError, match=msg):
154+
build.__main__.build_package(package_test_circular_requirements, '.', [distribution], isolation=False)
155+
156+
157+
@pytest.mark.parametrize('distribution', ['wheel', 'sdist'])
158+
def test_build_circular_requirements(package_test_circular_requirements, distribution):
159+
msg = 'Failed to validate `build-system` in pyproject.toml, dependency cycle detected: `flit_core==3.5.1` -> `tomli`'
160+
with pytest.raises(build.CircularBuildSystemDependencyError, match=msg):
161+
build.__main__.build_package(package_test_circular_requirements, '.', [distribution])
162+
163+
150164
@pytest.mark.parametrize(
151165
['missing_deps', 'output'],
152166
[

tox.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ commands =
6060
description = check minimum versions required of all dependencies
6161
skip_install = true
6262
commands =
63-
pip install .[test] -c tests/constraints.txt
63+
pip install .[test] -c tests/constraints.txt -r tests/requirements.txt
6464
pytest -rsx tests {posargs:-n auto}
6565

6666
[testenv:docs]

0 commit comments

Comments
 (0)