Skip to content
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
68 changes: 67 additions & 1 deletion src/fromager/dependencies.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import ast
import copy
import logging
import os
Expand Down Expand Up @@ -102,9 +103,74 @@ def default_get_build_system_dependencies(
"""Get build system requirements

Defaults to ``[build-system] requires`` from ``pyproject.toml``.

When ``setup.py`` uses APIs removed in newer setuptools versions,
a version cap is appended automatically:

- ``setuptools<81`` when ``setup.py`` passes ``dry_run=`` keyword
arguments (removed in setuptools 81)
- ``setuptools<82`` when ``setup.py`` imports ``pkg_resources``
(removed in setuptools 82)
"""
pyproject_toml = get_pyproject_contents(build_dir)
return typing.cast(list[str], get_build_backend(pyproject_toml)["requires"])
requires = list(
typing.cast(list[str], get_build_backend(pyproject_toml)["requires"])
)
constraint = _get_setuptools_constraint(sdist_root_dir)
if constraint:
logger.info(
"%s: auto-adding %s (setup.py uses removed APIs)", req.name, constraint
)
requires.append(constraint)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if there is already a setuptools requirement in the list? Shouldn't we modify that requirement instead of just adding another one?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the code has to carefully merge constraints. If the upstream project or our downstream project overrides set a lower ceiling, then the new code must not raise the ceiling.

return requires


def _get_setuptools_constraint(sdist_root_dir: pathlib.Path) -> str | None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function should look into build_dir, not sdist_root_dir.

"""Return a setuptools version cap if setup.py uses removed APIs.

- setuptools 81 removed ``distutils.spawn(dry_run=...)`` and
``distutils.dir_util.remove_tree(dry_run=...)``
- setuptools 82 removed ``pkg_resources`` entirely

Parses the AST to avoid false positives from string matches in
comments or string literals.

Returns ``"setuptools<81"``, ``"setuptools<82"``, or ``None``.
The tighter constraint wins when both apply.
"""
setup_py = sdist_root_dir / "setup.py"
if not setup_py.is_file():
return None
try:
source = setup_py.read_text(encoding="utf-8", errors="replace")
tree = ast.parse(source, filename=str(setup_py))
except (OSError, SyntaxError):
return None

findings: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
if alias.name == "pkg_resources" or alias.name.startswith(
"pkg_resources."
):
findings.add("pkg_resources")
elif isinstance(node, ast.ImportFrom):
if node.module is not None and (
node.module == "pkg_resources"
or node.module.startswith("pkg_resources.")
):
findings.add("pkg_resources")
elif isinstance(node, ast.Call):
for kw in node.keywords:
if kw.arg == "dry_run":
findings.add("dry_run")
Comment on lines +164 to +167

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Restrict dry_run detection to removed APIs.

Any local call such as copy_assets(dry_run=True) adds setuptools<81 despite not using setuptools functionality, potentially conflicting with a package’s declared setuptools requirement. Resolve the call target against tracked removed-API imports, and add a regression for an unrelated local dry_run parameter. Setuptools 81 removed setup.py dry-run support, not arbitrary Python keyword arguments. (setuptools.pypa.io)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/fromager/dependencies.py` around lines 164 - 167, Update the AST Call
handling in the dependency analysis so dry_run is recorded only when the call
target resolves to a tracked removed-API import, rather than for every keyword
named dry_run. Preserve existing removed-API detection and add a regression test
confirming unrelated local calls such as copy_assets are ignored.


if "dry_run" in findings:
return "setuptools<81"
if "pkg_resources" in findings:
return "setuptools<82"
return None


def get_build_backend_dependencies(
Expand Down
85 changes: 85 additions & 0 deletions tests/test_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -506,3 +506,88 @@ def test_get_metadata_from_wheel_validation_disabled(tmp_path: pathlib.Path) ->
# Assert: Should still parse the basic fields
assert metadata.name == "testpkg"
assert str(metadata.version) == "1.0.0"


class TestGetSetuptoolsConstraint:
"""Tests for _get_setuptools_constraint."""

def test_pkg_resources_import(self, tmp_path: pathlib.Path) -> None:
(tmp_path / "setup.py").write_text(
"import pkg_resources\nfrom setuptools import setup\nsetup(name='foo')\n"
)
assert dependencies._get_setuptools_constraint(tmp_path) == "setuptools<82"

def test_pkg_resources_from_import(self, tmp_path: pathlib.Path) -> None:
(tmp_path / "setup.py").write_text(
"from pkg_resources import get_distribution\n"
"from setuptools import setup\n"
"setup(name='foo')\n"
)
assert dependencies._get_setuptools_constraint(tmp_path) == "setuptools<82"

def test_pkg_resources_submodule(self, tmp_path: pathlib.Path) -> None:
(tmp_path / "setup.py").write_text(
"import pkg_resources.extern\n"
"from setuptools import setup\n"
"setup(name='foo')\n"
)
assert dependencies._get_setuptools_constraint(tmp_path) == "setuptools<82"

def test_dry_run_keyword(self, tmp_path: pathlib.Path) -> None:
(tmp_path / "setup.py").write_text(
"from distutils.spawn import spawn\nspawn(['ls'], dry_run=True)\n"
)
assert dependencies._get_setuptools_constraint(tmp_path) == "setuptools<81"

def test_both_returns_tighter(self, tmp_path: pathlib.Path) -> None:
(tmp_path / "setup.py").write_text(
"import pkg_resources\n"
"from distutils.dir_util import remove_tree\n"
"remove_tree('build', dry_run=False)\n"
)
assert dependencies._get_setuptools_constraint(tmp_path) == "setuptools<81"

def test_clean_setup_py(self, tmp_path: pathlib.Path) -> None:
(tmp_path / "setup.py").write_text(
"from setuptools import setup\nsetup(name='foo', version='1.0')\n"
)
assert dependencies._get_setuptools_constraint(tmp_path) is None

def test_no_setup_py(self, tmp_path: pathlib.Path) -> None:
assert dependencies._get_setuptools_constraint(tmp_path) is None

def test_syntax_error(self, tmp_path: pathlib.Path) -> None:
(tmp_path / "setup.py").write_text("def broken(:\n")
assert dependencies._get_setuptools_constraint(tmp_path) is None

def test_pkg_resources_in_string_not_detected(self, tmp_path: pathlib.Path) -> None:
(tmp_path / "setup.py").write_text(
"from setuptools import setup\n"
"setup(name='foo', description='uses pkg_resources internally')\n"
)
assert dependencies._get_setuptools_constraint(tmp_path) is None


@patch("fromager.dependencies._write_requirements_file")
@_clean_build_artifacts
def test_default_build_system_deps_adds_setuptools_constraint(
_: Mock, tmp_context: context.WorkContext, tmp_path: pathlib.Path
) -> None:
(tmp_path / "pyproject.toml").write_text(
"[build-system]\n"
'requires = ["setuptools"]\n'
'build-backend = "setuptools.build_meta"\n'
)
(tmp_path / "setup.py").write_text(
"import pkg_resources\nfrom setuptools import setup\nsetup(name='foo')\n"
)
results = dependencies.get_build_system_dependencies(
ctx=tmp_context,
req=Requirement("foo"),
version=Version("1.0.0"),
sdist_root_dir=tmp_path,
)
names = {r.name for r in results}
assert "setuptools" in names
constraints = [r for r in results if r.name == "setuptools"]
assert any(str(r) == "setuptools<82" for r in constraints)
Loading