From f876e101513b154ad612172bc5317badcb3ee1d7 Mon Sep 17 00:00:00 2001 From: Vikash Shaw Date: Thu, 23 Jul 2026 16:52:01 -0400 Subject: [PATCH] feat: auto-cap setuptools when setup.py uses removed APIs Detect pkg_resources imports and dry_run keyword arguments in setup.py via AST parsing, and automatically append a setuptools version cap to build-system requirements. setuptools 81 removed distutils dry_run parameters, and setuptools 82 removed pkg_resources entirely. Closes #1263 Co-Authored-By: Claude Opus 4.6 Signed-off-by: Vikash Shaw --- src/fromager/dependencies.py | 68 ++++++++++++++++++++++++++++- tests/test_dependencies.py | 85 ++++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 1 deletion(-) diff --git a/src/fromager/dependencies.py b/src/fromager/dependencies.py index d80899e85..3b2fc643a 100644 --- a/src/fromager/dependencies.py +++ b/src/fromager/dependencies.py @@ -1,5 +1,6 @@ from __future__ import annotations +import ast import copy import logging import os @@ -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) + return requires + + +def _get_setuptools_constraint(sdist_root_dir: pathlib.Path) -> str | None: + """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") + + if "dry_run" in findings: + return "setuptools<81" + if "pkg_resources" in findings: + return "setuptools<82" + return None def get_build_backend_dependencies( diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py index aad6d73fb..5f2dcc85e 100644 --- a/tests/test_dependencies.py +++ b/tests/test_dependencies.py @@ -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)