diff --git a/python/rpdk/python/codegen.py b/python/rpdk/python/codegen.py index 8857aa9a..119fd726 100644 --- a/python/rpdk/python/codegen.py +++ b/python/rpdk/python/codegen.py @@ -322,6 +322,7 @@ def _make_pip_command(base_path): str(base_path / "requirements.txt"), "--target", str(base_path / "build"), + "setuptools<82", ] @staticmethod @@ -399,9 +400,15 @@ def _pip_build(cls, base_path): LOG.warning("Starting pip build.") try: # On windows run pip command through the default shell (CMD) + # Build a quoted string so CMD doesn't misinterpret '<' or '>' + # in version specifiers (e.g. 'setuptools<82') as redirection. if os.name == "nt": + cmd_str = " ".join( + f'"{a}"' if any(c in a for c in ("<", ">", "&", "|")) else a + for a in command + ) completed_proc = subprocess_run( # nosec - command, + cmd_str, stdout=PIPE, stderr=PIPE, cwd=base_path, diff --git a/python/rpdk/python/templates/requirements.txt b/python/rpdk/python/templates/requirements.txt index 497769ea..7d817552 100644 --- a/python/rpdk/python/templates/requirements.txt +++ b/python/rpdk/python/templates/requirements.txt @@ -1 +1,2 @@ {{ support_lib_name }}>=2.1.9 +setuptools<82 diff --git a/setup.py b/setup.py index af042812..1e171b5f 100644 --- a/setup.py +++ b/setup.py @@ -40,7 +40,7 @@ def find_version(*file_paths): install_requires=[ "cloudformation-cli>=0.2.26", "types-dataclasses>=0.1.5", - "setuptools", + "setuptools<82", ], entry_points={ "rpdk.v1.languages": [ diff --git a/tests/plugin/codegen_test.py b/tests/plugin/codegen_test.py index b62f3512..a3b4b447 100644 --- a/tests/plugin/codegen_test.py +++ b/tests/plugin/codegen_test.py @@ -540,6 +540,27 @@ def test__pip_build_called_process_error(tmp_path): assert isinstance(excinfo.value.__cause__, (FileNotFoundError, CalledProcessError)) +def test__pip_build_windows_quotes_version_specifiers(tmp_path): + """On Windows, args with '<' must be quoted so CMD doesn't misinterpret them.""" + command = ["pip", "install", "setuptools<82"] + patch_cmd = patch.object( + PythonLanguagePlugin, "_make_pip_command", return_value=command + ) + patch_os = patch("rpdk.python.codegen.os.name", "nt") + patch_run = patch("rpdk.python.codegen.subprocess_run", autospec=True) + + with patch_cmd, patch_os, patch_run as mock_run: + PythonLanguagePlugin._pip_build(tmp_path) + + call_args = mock_run.call_args + cmd_arg = call_args[0][0] + assert isinstance( + cmd_arg, str + ), "Windows branch must pass a string to subprocess_run" + assert "setuptools<82" in cmd_arg + assert call_args[1]["shell"] is True + + def test__build_pip(plugin): plugin._use_docker = False plugin._no_docker = True @@ -553,6 +574,17 @@ def test__build_pip(plugin): mock_pip.assert_called_once_with(sentinel.base_path) +def test__make_pip_command_pins_setuptools_lt_82(tmp_path): + """setuptools<82 must be in the pip command to prevent pkg_resources removal.""" + cmd = PythonLanguagePlugin._make_pip_command(tmp_path) + assert "setuptools<82" in cmd, ( + "setuptools<82 must be pinned in pip command — setuptools 82+ removes " + "pkg_resources which breaks cloudformation-cli-python-lib at runtime" + ) + assert str(tmp_path / "requirements.txt") in cmd + assert str(tmp_path / "build") in cmd + + def test__build_pip_posix(plugin): patch_os_name = patch("rpdk.python.codegen.os.name", "posix") patch_subproc = patch("rpdk.python.codegen.subprocess_run") @@ -581,7 +613,10 @@ def test__build_pip_windows(plugin): plugin._pip_build(temppath) mock_subproc.assert_called_once_with( - plugin._make_pip_command(temppath), + " ".join( + f'"{a}"' if any(c in a for c in ("<", ">", "&", "|")) else a + for a in plugin._make_pip_command(temppath) + ), stdout=ANY, stderr=ANY, cwd=temppath,