From 8d01a35b03cd896642bdf76d4caa28f6c4febd20 Mon Sep 17 00:00:00 2001 From: Roger Zhang Date: Fri, 28 Aug 2026 14:36:46 -0700 Subject: [PATCH 1/5] fix(init): run cookiecutter template hooks from the packaged binary Cookiecutter runs a Python hook as [sys.executable, script]. In a PyInstaller bundle sys.executable is sam itself, so the hook became 'sam /tmp/xxxx.py', which printed help and exited 0 -- cookiecutter read that as success and the hook was silently skipped. Supply a real interpreter instead: a system python3 when present, else this executable re-launched in hook mode. Also correct INCOMPATIBLE_PARAM_MESSAGE, which still expected the parameter order from before #9176 generated the hint from the enforced combinations. --- samcli/__main__.py | 4 + samcli/lib/init/__init__.py | 5 +- samcli/lib/utils/hook_script.py | 124 ++++++++++++++++ tests/integration/init/test_init_command.py | 2 +- tests/unit/lib/utils/test_hook_script.py | 156 ++++++++++++++++++++ 5 files changed, 289 insertions(+), 2 deletions(-) create mode 100644 samcli/lib/utils/hook_script.py create mode 100644 tests/unit/lib/utils/test_hook_script.py diff --git a/samcli/__main__.py b/samcli/__main__.py index eea6ff71312..a5e9f956aa4 100644 --- a/samcli/__main__.py +++ b/samcli/__main__.py @@ -5,8 +5,12 @@ """ from samcli.cli.main import cli # pragma: no cover +from samcli.lib.utils.hook_script import run_hook_script_if_requested # pragma: no cover if __name__ == "__main__": # pragma: no cover + # A bundle runs cookiecutter's Python hooks by re-launching itself, so claim those invocations + # before the CLI treats the script path as a command name. + run_hook_script_if_requested() # NOTE(TheSriram): prog_name is always set to "sam". This way when the CLI is invoked as a module, # the help text that is generated still says "sam" instead of "__main__". cli(prog_name="sam") diff --git a/samcli/lib/init/__init__.py b/samcli/lib/init/__init__.py index 5e8d284ce32..f3d7472fb06 100644 --- a/samcli/lib/init/__init__.py +++ b/samcli/lib/init/__init__.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import Dict, Optional +from cookiecutter import hooks as cookiecutter_hooks from cookiecutter.exceptions import CookiecutterException, RepositoryNotFound, UnknownRepoType from cookiecutter.main import cookiecutter @@ -22,6 +23,7 @@ from samcli.lib.init.template_modifiers.xray_tracing_template_modifier import XRayTracingTemplateModifier from samcli.lib.telemetry.event import EventName, EventTracker, UsedFeature from samcli.lib.utils import osutils +from samcli.lib.utils.hook_script import patched_hook_runner from samcli.lib.utils.packagetype import ZIP from samcli.local.common.runtime_template import RUNTIME_DEP_TEMPLATE_MAPPING, is_custom_runtime @@ -119,7 +121,8 @@ def generate_project( LOG.debug("Baking a new template with cookiecutter with all parameters") # cookiecutter returns the directory it created, which is the only reliable way to know # where the project landed when the template chooses its own project directory name. - project_directory = cookiecutter(**params) + with patched_hook_runner(cookiecutter_hooks): + project_directory = cookiecutter(**params) # Fixes gradlew line ending issue caused by Windows git # gradlew is a shell script which should not have CR LF line endings # Putting the conversion after cookiecutter as cookiecutter processing will also change the line endings diff --git a/samcli/lib/utils/hook_script.py b/samcli/lib/utils/hook_script.py new file mode 100644 index 00000000000..d24c3841fa3 --- /dev/null +++ b/samcli/lib/utils/hook_script.py @@ -0,0 +1,124 @@ +""" +Support for running cookiecutter template hooks from a PyInstaller bundle. + +Cookiecutter runs a Python hook as ``[sys.executable, script]``. In a bundle ``sys.executable`` is +the sam executable itself, so the hook never runs. This module supplies a real interpreter: a system +python3 when one is available, otherwise this executable re-launched in hook mode. +""" + +import logging +import os +import runpy +import shutil +import subprocess +import sys +from contextlib import contextmanager +from types import ModuleType +from typing import Iterator, Optional + +from samcli.lib.utils.subprocess_utils import is_pyinstaller_bundle + +LOG = logging.getLogger(__name__) + +# Set on the hook subprocess so a re-launched bundle runs the script instead of parsing a command name. +HOOK_SCRIPT_ENV_VAR = "SAM_CLI_RUN_HOOK_SCRIPT" + +# A bundle ships no interpreter of its own, so a system one is preferred: it gives hooks the same +# environment they get from a pip install, rather than this bundle's Python and dependencies. +_INTERPRETER_CANDIDATES = ("python3", "python") +_PROBE_TIMEOUT = 10 + + +def run_hook_script_if_requested() -> None: + """Run the script named in argv and exit, when re-launched by the patched hook runner.""" + # Popped rather than read so a hook that shells out to sam again gets the normal CLI. + requested = os.environ.pop(HOOK_SCRIPT_ENV_VAR, None) == "1" + arguments = sys.argv[1:] + if not requested or not arguments: + return + + LOG.debug("Running template hook script %s through this executable", arguments[0]) + runpy.run_path(arguments[0], run_name="__main__") + sys.exit(0) + + +def find_system_interpreter() -> Optional[str]: + """Return the path to a usable system Python 3, or None if there isn't one.""" + for candidate in _INTERPRETER_CANDIDATES: + path = shutil.which(candidate) + if not path or os.path.realpath(path) == os.path.realpath(sys.executable): + continue + # Executed rather than trusted because Windows ships a "python" App Execution Alias that + # resolves on PATH without being an interpreter. + try: + completed = subprocess.run( + [path, "-c", "import sys; sys.exit(0 if sys.version_info[0] == 3 else 1)"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=_PROBE_TIMEOUT, + check=False, + ) + except (OSError, subprocess.SubprocessError): + continue + if completed.returncode == 0: + return path + return None + + +@contextmanager +def _replaced_attribute(target: object, name: str, value: object) -> Iterator[None]: + """Set an attribute for the duration of the block, restoring whatever was there before.""" + original = getattr(target, name) + setattr(target, name, value) + try: + yield + finally: + setattr(target, name, original) + + +@contextmanager +def _hook_script_env() -> Iterator[None]: + """Mark the environment so the re-launched executable runs the hook script.""" + original = os.environ.get(HOOK_SCRIPT_ENV_VAR) + os.environ[HOOK_SCRIPT_ENV_VAR] = "1" + try: + yield + finally: + if original is None: + os.environ.pop(HOOK_SCRIPT_ENV_VAR, None) + else: + os.environ[HOOK_SCRIPT_ENV_VAR] = original + + +@contextmanager +def patched_hook_runner(hooks_module: ModuleType) -> Iterator[None]: + """Make cookiecutter's Python hooks runnable while frozen; a no-op when not frozen. + + The module is passed in so this stays importable without pulling in cookiecutter, which every + sam invocation would otherwise pay for at startup. + """ + if not is_pyinstaller_bundle(): + yield + return + + original_run_script = hooks_module.run_script + + def run_script(script_path: str, cwd: str = ".") -> None: + # Only .py hooks go through an interpreter; anything else already runs on its own. + if not script_path.endswith(".py"): + original_run_script(script_path, cwd) + return + + interpreter = find_system_interpreter() + if interpreter: + LOG.debug("Running template hook with system interpreter %s", interpreter) + with _replaced_attribute(sys, "executable", interpreter): + original_run_script(script_path, cwd) + return + + LOG.debug("No system interpreter found, re-launching this executable to run the template hook") + with _hook_script_env(): + original_run_script(script_path, cwd) + + with _replaced_attribute(hooks_module, "run_script", run_script): + yield diff --git a/tests/integration/init/test_init_command.py b/tests/integration/init/test_init_command.py index e3420963df1..c0ece5a0b22 100644 --- a/tests/integration/init/test_init_command.py +++ b/tests/integration/init/test_init_command.py @@ -684,7 +684,7 @@ def _assert_template_with_cfn_lint(self, cwd): You can run 'sam init' without any options for an interactive initialization flow, or you can provide one of the following required parameter combinations: \t--name, --location, or \t--name, --package-type, --base-image, or -\t--name, --runtime, --app-template, --dependency-manager +\t--name, --runtime, --dependency-manager, --app-template """ diff --git a/tests/unit/lib/utils/test_hook_script.py b/tests/unit/lib/utils/test_hook_script.py new file mode 100644 index 00000000000..a89ca909c42 --- /dev/null +++ b/tests/unit/lib/utils/test_hook_script.py @@ -0,0 +1,156 @@ +import inspect +import os +import sys +from types import SimpleNamespace +from unittest import TestCase +from unittest.mock import Mock, patch + +from cookiecutter import hooks as cookiecutter_hooks + +from samcli.lib.utils.hook_script import ( + HOOK_SCRIPT_ENV_VAR, + find_system_interpreter, + patched_hook_runner, + run_hook_script_if_requested, +) + + +def _hooks_module(recorder): + """A stand-in for cookiecutter.hooks whose run_script records the interpreter it would use.""" + + def run_script(script_path, cwd="."): + recorder.append((sys.executable, os.environ.get(HOOK_SCRIPT_ENV_VAR))) + + return SimpleNamespace(run_script=run_script) + + +class TestPatchedHookRunner(TestCase): + @patch("samcli.lib.utils.hook_script.is_pyinstaller_bundle", return_value=False) + def test_not_patched_when_not_frozen(self, patched_bundle): + module = _hooks_module([]) + original = module.run_script + with patched_hook_runner(module): + self.assertIs(module.run_script, original) + + @patch("samcli.lib.utils.hook_script.is_pyinstaller_bundle", return_value=True) + def test_restores_run_script_afterwards(self, patched_bundle): + module = _hooks_module([]) + original = module.run_script + with patched_hook_runner(module): + self.assertIsNot(module.run_script, original) + self.assertIs(module.run_script, original) + + @patch("samcli.lib.utils.hook_script.is_pyinstaller_bundle", return_value=True) + def test_restores_run_script_when_body_raises(self, patched_bundle): + module = _hooks_module([]) + original = module.run_script + with self.assertRaises(ValueError): + with patched_hook_runner(module): + raise ValueError("boom") + self.assertIs(module.run_script, original) + + @patch("samcli.lib.utils.hook_script.find_system_interpreter", return_value="/usr/bin/python3") + @patch("samcli.lib.utils.hook_script.is_pyinstaller_bundle", return_value=True) + def test_system_interpreter_used_for_python_hook(self, patched_bundle, patched_find): + calls = [] + module = _hooks_module(calls) + original_executable = sys.executable + with patched_hook_runner(module): + module.run_script("post_gen_project.py") + self.assertEqual(calls, [("/usr/bin/python3", None)]) + self.assertEqual(sys.executable, original_executable) + + @patch("samcli.lib.utils.hook_script.find_system_interpreter", return_value=None) + @patch("samcli.lib.utils.hook_script.is_pyinstaller_bundle", return_value=True) + def test_falls_back_to_relaunching_this_executable(self, patched_bundle, patched_find): + calls = [] + module = _hooks_module(calls) + with patched_hook_runner(module): + module.run_script("post_gen_project.py") + # The executable is left alone; the env var tells the re-launched bundle to run the script. + self.assertEqual(calls, [(sys.executable, "1")]) + self.assertNotIn(HOOK_SCRIPT_ENV_VAR, os.environ) + + @patch("samcli.lib.utils.hook_script.find_system_interpreter") + @patch("samcli.lib.utils.hook_script.is_pyinstaller_bundle", return_value=True) + def test_non_python_hook_is_delegated_untouched(self, patched_bundle, patched_find): + calls = [] + module = _hooks_module(calls) + with patched_hook_runner(module): + module.run_script("post_gen_project.sh") + self.assertEqual(calls, [(sys.executable, None)]) + patched_find.assert_not_called() + + +class TestFindSystemInterpreter(TestCase): + @patch("samcli.lib.utils.hook_script.shutil.which", return_value=None) + def test_returns_none_when_nothing_on_path(self, patched_which): + self.assertIsNone(find_system_interpreter()) + + @patch("samcli.lib.utils.hook_script.subprocess.run") + @patch("samcli.lib.utils.hook_script.shutil.which") + def test_skips_candidate_that_is_this_executable(self, patched_which, patched_run): + patched_which.return_value = sys.executable + self.assertIsNone(find_system_interpreter()) + patched_run.assert_not_called() + + @patch("samcli.lib.utils.hook_script.subprocess.run", return_value=Mock(returncode=1)) + @patch("samcli.lib.utils.hook_script.shutil.which", return_value="/fake/python3") + def test_skips_candidate_that_is_not_an_interpreter(self, patched_which, patched_run): + self.assertIsNone(find_system_interpreter()) + + @patch("samcli.lib.utils.hook_script.subprocess.run", return_value=Mock(returncode=0)) + @patch("samcli.lib.utils.hook_script.shutil.which", return_value="/fake/python3") + def test_returns_usable_interpreter(self, patched_which, patched_run): + self.assertEqual(find_system_interpreter(), "/fake/python3") + + @patch("samcli.lib.utils.hook_script.subprocess.run", side_effect=OSError("nope")) + @patch("samcli.lib.utils.hook_script.shutil.which", return_value="/fake/python3") + def test_skips_candidate_that_cannot_be_executed(self, patched_which, patched_run): + self.assertIsNone(find_system_interpreter()) + + +class TestRunHookScriptIfRequested(TestCase): + @patch("samcli.lib.utils.hook_script.runpy.run_path") + def test_no_op_without_env_var(self, patched_run_path): + os.environ.pop(HOOK_SCRIPT_ENV_VAR, None) + run_hook_script_if_requested() + patched_run_path.assert_not_called() + + @patch("samcli.lib.utils.hook_script.runpy.run_path") + def test_runs_script_and_exits(self, patched_run_path): + with patch.dict(os.environ, {HOOK_SCRIPT_ENV_VAR: "1"}): + with patch.object(sys, "argv", ["sam", "/tmp/hook.py"]): + with self.assertRaises(SystemExit) as context: + run_hook_script_if_requested() + self.assertEqual(context.exception.code, 0) + patched_run_path.assert_called_once_with("/tmp/hook.py", run_name="__main__") + + @patch("samcli.lib.utils.hook_script.runpy.run_path") + def test_env_var_removed_so_nested_sam_calls_get_the_cli(self, patched_run_path): + with patch.dict(os.environ, {HOOK_SCRIPT_ENV_VAR: "1"}): + with patch.object(sys, "argv", ["sam", "/tmp/hook.py"]): + with self.assertRaises(SystemExit): + run_hook_script_if_requested() + self.assertNotIn(HOOK_SCRIPT_ENV_VAR, os.environ) + + @patch("samcli.lib.utils.hook_script.runpy.run_path") + def test_no_op_without_a_script_argument(self, patched_run_path): + with patch.dict(os.environ, {HOOK_SCRIPT_ENV_VAR: "1"}): + with patch.object(sys, "argv", ["sam"]): + run_hook_script_if_requested() + patched_run_path.assert_not_called() + + +class TestCookiecutterPatchTarget(TestCase): + """Guards the seam: a cookiecutter bump inside the pin must not silently un-patch us.""" + + def test_run_script_exists_with_expected_signature(self): + self.assertTrue(callable(cookiecutter_hooks.run_script)) + parameters = list(inspect.signature(cookiecutter_hooks.run_script).parameters) + self.assertEqual(parameters, ["script_path", "cwd"]) + + def test_run_script_still_uses_sys_executable(self): + # The whole fix rests on run_script reading sys.executable at call time. + source = inspect.getsource(cookiecutter_hooks.run_script) + self.assertIn("sys.executable", source) From b13769305008ff4dcbc34a0aa2e56bcd60bd1c6d Mon Sep 17 00:00:00 2001 From: Roger Zhang Date: Fri, 28 Aug 2026 14:59:13 -0700 Subject: [PATCH 2/5] fix(init): isolate library paths in hook mode and patch the pipeline template path The re-launched bundle never reaches the CLI callback that undoes the bootloader's library paths, so hooks shelling out to git/npm/pip inherited them. Also wrap the second cookiecutter call site, reachable via sam pipeline init with a custom template location. --- samcli/lib/cookiecutter/template.py | 17 ++++++++++------- samcli/lib/utils/hook_script.py | 5 ++++- tests/unit/lib/utils/test_hook_script.py | 18 +++++++++++++++++- 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/samcli/lib/cookiecutter/template.py b/samcli/lib/cookiecutter/template.py index 62c2527e7d0..822e3af627f 100644 --- a/samcli/lib/cookiecutter/template.py +++ b/samcli/lib/cookiecutter/template.py @@ -6,6 +6,7 @@ import logging from typing import Dict, List, Optional +from cookiecutter import hooks as cookiecutter_hooks from cookiecutter.exceptions import RepositoryNotFound, UnknownRepoType from cookiecutter.main import cookiecutter @@ -20,6 +21,7 @@ from samcli.lib.cookiecutter.plugin import Plugin from samcli.lib.cookiecutter.processor import Processor from samcli.lib.init.arbitrary_project import generate_non_cookiecutter_project +from samcli.lib.utils.hook_script import patched_hook_runner LOG = logging.getLogger(__name__) @@ -167,13 +169,14 @@ def generate_project(self, context: Dict, output_dir: str) -> None: try: LOG.debug("Baking a new template with cookiecutter with all parameters") - cookiecutter( - template=self._location, - output_dir=output_dir, - no_input=True, - extra_context=context, - overwrite_if_exists=True, - ) + with patched_hook_runner(cookiecutter_hooks): + cookiecutter( + template=self._location, + output_dir=output_dir, + no_input=True, + extra_context=context, + overwrite_if_exists=True, + ) except RepositoryNotFound: # cookiecutter.json is not found in the template. Let's just clone it directly without # using cookiecutter and call it done. diff --git a/samcli/lib/utils/hook_script.py b/samcli/lib/utils/hook_script.py index d24c3841fa3..dc7082c6849 100644 --- a/samcli/lib/utils/hook_script.py +++ b/samcli/lib/utils/hook_script.py @@ -16,7 +16,7 @@ from types import ModuleType from typing import Iterator, Optional -from samcli.lib.utils.subprocess_utils import is_pyinstaller_bundle +from samcli.lib.utils.subprocess_utils import is_pyinstaller_bundle, isolate_library_paths_for_subprocess LOG = logging.getLogger(__name__) @@ -38,6 +38,9 @@ def run_hook_script_if_requested() -> None: return LOG.debug("Running template hook script %s through this executable", arguments[0]) + # The bootloader re-points library paths into the bundle for this process, and the CLI callback + # that normally undoes that is never reached here. Hooks routinely shell out to git, npm and pip. + isolate_library_paths_for_subprocess() runpy.run_path(arguments[0], run_name="__main__") sys.exit(0) diff --git a/tests/unit/lib/utils/test_hook_script.py b/tests/unit/lib/utils/test_hook_script.py index a89ca909c42..7a10fd6093d 100644 --- a/tests/unit/lib/utils/test_hook_script.py +++ b/tests/unit/lib/utils/test_hook_script.py @@ -134,8 +134,24 @@ def test_env_var_removed_so_nested_sam_calls_get_the_cli(self, patched_run_path) run_hook_script_if_requested() self.assertNotIn(HOOK_SCRIPT_ENV_VAR, os.environ) + def test_library_paths_isolated_before_the_hook_runs(self): + # The bootloader re-points library paths into the bundle for this process, and the CLI + # callback that normally undoes it never runs on this path. + order = [] + with patch( + "samcli.lib.utils.hook_script.isolate_library_paths_for_subprocess", + side_effect=lambda: order.append("isolate"), + ): + with patch("samcli.lib.utils.hook_script.runpy.run_path", side_effect=lambda *a, **k: order.append("run")): + with patch.dict(os.environ, {HOOK_SCRIPT_ENV_VAR: "1"}): + with patch.object(sys, "argv", ["sam", "/tmp/hook.py"]): + with self.assertRaises(SystemExit): + run_hook_script_if_requested() + self.assertEqual(order, ["isolate", "run"]) + + @patch("samcli.lib.utils.hook_script.isolate_library_paths_for_subprocess") @patch("samcli.lib.utils.hook_script.runpy.run_path") - def test_no_op_without_a_script_argument(self, patched_run_path): + def test_no_op_without_a_script_argument(self, patched_run_path, patched_isolate): with patch.dict(os.environ, {HOOK_SCRIPT_ENV_VAR: "1"}): with patch.object(sys, "argv", ["sam"]): run_hook_script_if_requested() From 7ec31c385890b27377b1c515e1489fc012c68eba Mon Sep 17 00:00:00 2001 From: Roger Zhang Date: Fri, 28 Aug 2026 15:11:40 -0700 Subject: [PATCH 3/5] fix(init): give hooks the argv they expect and probe the Windows py launcher runpy leaves our second argument in sys.argv, so a hook saw [script, script] instead of [script]. Also add py3/py to interpreter discovery, matching _get_python_command_name, since a default python.org Windows install puts only py.exe on PATH. --- samcli/lib/utils/hook_script.py | 11 ++++++++--- tests/unit/lib/utils/test_hook_script.py | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/samcli/lib/utils/hook_script.py b/samcli/lib/utils/hook_script.py index dc7082c6849..4a72b1036eb 100644 --- a/samcli/lib/utils/hook_script.py +++ b/samcli/lib/utils/hook_script.py @@ -24,8 +24,10 @@ HOOK_SCRIPT_ENV_VAR = "SAM_CLI_RUN_HOOK_SCRIPT" # A bundle ships no interpreter of its own, so a system one is preferred: it gives hooks the same -# environment they get from a pip install, rather than this bundle's Python and dependencies. -_INTERPRETER_CANDIDATES = ("python3", "python") +# environment they get from a pip install, rather than this bundle's Python and dependencies. The +# order matches _get_python_command_name in the terraform prepare hook, and includes the Windows +# launcher because a default python.org install puts only py.exe on PATH. +_INTERPRETER_CANDIDATES = ("python3", "py3", "python", "py") _PROBE_TIMEOUT = 10 @@ -41,7 +43,10 @@ def run_hook_script_if_requested() -> None: # The bootloader re-points library paths into the bundle for this process, and the CLI callback # that normally undoes that is never reached here. Hooks routinely shell out to git, npm and pip. isolate_library_paths_for_subprocess() - runpy.run_path(arguments[0], run_name="__main__") + # A hook launched by a real interpreter sees only its own path in argv; run_path fixes argv[0] + # but would leave our second argument behind, so give the hook the argv it expects. + with _replaced_attribute(sys, "argv", [arguments[0]]): + runpy.run_path(arguments[0], run_name="__main__") sys.exit(0) diff --git a/tests/unit/lib/utils/test_hook_script.py b/tests/unit/lib/utils/test_hook_script.py index 7a10fd6093d..ee675b2a4cf 100644 --- a/tests/unit/lib/utils/test_hook_script.py +++ b/tests/unit/lib/utils/test_hook_script.py @@ -109,6 +109,13 @@ def test_returns_usable_interpreter(self, patched_which, patched_run): def test_skips_candidate_that_cannot_be_executed(self, patched_which, patched_run): self.assertIsNone(find_system_interpreter()) + @patch("samcli.lib.utils.hook_script.subprocess.run", return_value=Mock(returncode=0)) + @patch("samcli.lib.utils.hook_script.shutil.which") + def test_finds_windows_launcher_when_nothing_else_is_on_path(self, patched_which, patched_run): + # A default python.org install on Windows puts only py.exe on PATH. + patched_which.side_effect = lambda name: r"C:\Windows\py.exe" if name == "py" else None + self.assertEqual(find_system_interpreter(), r"C:\Windows\py.exe") + class TestRunHookScriptIfRequested(TestCase): @patch("samcli.lib.utils.hook_script.runpy.run_path") @@ -134,6 +141,18 @@ def test_env_var_removed_so_nested_sam_calls_get_the_cli(self, patched_run_path) run_hook_script_if_requested() self.assertNotIn(HOOK_SCRIPT_ENV_VAR, os.environ) + @patch("samcli.lib.utils.hook_script.isolate_library_paths_for_subprocess") + def test_hook_sees_only_its_own_path_in_argv(self, patched_isolate): + # A real interpreter gives the hook argv == [script]; our extra argument must not leak. + seen = [] + with patch("samcli.lib.utils.hook_script.runpy.run_path", side_effect=lambda *a, **k: seen.append(sys.argv)): + with patch.dict(os.environ, {HOOK_SCRIPT_ENV_VAR: "1"}): + with patch.object(sys, "argv", ["/usr/local/bin/sam", "/tmp/hook.py"]): + with self.assertRaises(SystemExit): + run_hook_script_if_requested() + self.assertEqual(sys.argv, ["/usr/local/bin/sam", "/tmp/hook.py"]) + self.assertEqual(seen, [["/tmp/hook.py"]]) + def test_library_paths_isolated_before_the_hook_runs(self): # The bootloader re-points library paths into the bundle for this process, and the CLI # callback that normally undoes it never runs on this path. From c1375001cb6b88df1a82b8471001b2febfeb9afb Mon Sep 17 00:00:00 2001 From: Roger Zhang Date: Mon, 31 Aug 2026 15:19:55 -0700 Subject: [PATCH 4/5] fix(init): hold hooks to requires-python and correct the interpreter rationale The probe accepted any Python 3.x, so a 3.6 /usr/bin/python3 could be preferred over the bundled 3.11 even though the project requires >=3.10. Also the comment claimed a system interpreter matches a pip install; it does not, since a pip install can import SAM's dependencies and a bare system python cannot. --- samcli/lib/utils/hook_script.py | 16 ++++++++++------ tests/unit/lib/utils/test_hook_script.py | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/samcli/lib/utils/hook_script.py b/samcli/lib/utils/hook_script.py index 4a72b1036eb..24ecd21f504 100644 --- a/samcli/lib/utils/hook_script.py +++ b/samcli/lib/utils/hook_script.py @@ -23,11 +23,14 @@ # Set on the hook subprocess so a re-launched bundle runs the script instead of parsing a command name. HOOK_SCRIPT_ENV_VAR = "SAM_CLI_RUN_HOOK_SCRIPT" -# A bundle ships no interpreter of its own, so a system one is preferred: it gives hooks the same -# environment they get from a pip install, rather than this bundle's Python and dependencies. The -# order matches _get_python_command_name in the terraform prepare hook, and includes the Windows -# launcher because a default python.org install puts only py.exe on PATH. +# A bundle ships no interpreter of its own. A system one is preferred for isolation, so that SAM's +# bundled dependencies do not become an implicit contract for template authors -- not for fidelity +# with a pip install, where hooks can in fact import SAM's dependencies. The order matches +# _get_python_command_name in the terraform prepare hook, and includes the Windows launcher because +# a default python.org install puts only py.exe on PATH. _INTERPRETER_CANDIDATES = ("python3", "py3", "python", "py") +# Matches requires-python, so a hook never sees an older Python than a pip install would give it. +_MINIMUM_PYTHON_VERSION = (3, 10) _PROBE_TIMEOUT = 10 @@ -57,10 +60,11 @@ def find_system_interpreter() -> Optional[str]: if not path or os.path.realpath(path) == os.path.realpath(sys.executable): continue # Executed rather than trusted because Windows ships a "python" App Execution Alias that - # resolves on PATH without being an interpreter. + # resolves on PATH without being an interpreter, and because /usr/bin/python3 is 3.6 on + # older distributions, where a hook using newer syntax would fail with a SyntaxError. try: completed = subprocess.run( - [path, "-c", "import sys; sys.exit(0 if sys.version_info[0] == 3 else 1)"], + [path, "-c", f"import sys; sys.exit(0 if sys.version_info >= {_MINIMUM_PYTHON_VERSION} else 1)"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=_PROBE_TIMEOUT, diff --git a/tests/unit/lib/utils/test_hook_script.py b/tests/unit/lib/utils/test_hook_script.py index ee675b2a4cf..ee473836c82 100644 --- a/tests/unit/lib/utils/test_hook_script.py +++ b/tests/unit/lib/utils/test_hook_script.py @@ -1,5 +1,6 @@ import inspect import os +import subprocess import sys from types import SimpleNamespace from unittest import TestCase @@ -8,6 +9,7 @@ from cookiecutter import hooks as cookiecutter_hooks from samcli.lib.utils.hook_script import ( + _MINIMUM_PYTHON_VERSION, HOOK_SCRIPT_ENV_VAR, find_system_interpreter, patched_hook_runner, @@ -109,6 +111,21 @@ def test_returns_usable_interpreter(self, patched_which, patched_run): def test_skips_candidate_that_cannot_be_executed(self, patched_which, patched_run): self.assertIsNone(find_system_interpreter()) + def test_probe_enforces_the_requires_python_floor(self): + # /usr/bin/python3 is 3.6 on older distributions, where a hook using newer syntax would hit + # a SyntaxError that a pip-installed sam would never produce. + with patch("samcli.lib.utils.hook_script.shutil.which", return_value="/fake/python3"): + with patch("samcli.lib.utils.hook_script.subprocess.run", return_value=Mock(returncode=0)) as patched_run: + find_system_interpreter() + snippet = patched_run.call_args[0][0][2] + + # Outside the patch, since patching the module attribute also patches subprocess.run here. + self.assertIn(str(_MINIMUM_PYTHON_VERSION), snippet) + # Run it for real, so the floor is verified rather than only spelled out. + self.assertEqual(subprocess.run([sys.executable, "-c", snippet], check=False).returncode, 0) + unreachable = snippet.replace(str(_MINIMUM_PYTHON_VERSION), "(99, 0)") + self.assertEqual(subprocess.run([sys.executable, "-c", unreachable], check=False).returncode, 1) + @patch("samcli.lib.utils.hook_script.subprocess.run", return_value=Mock(returncode=0)) @patch("samcli.lib.utils.hook_script.shutil.which") def test_finds_windows_launcher_when_nothing_else_is_on_path(self, patched_which, patched_run): From a7b00a0fc8cf952b67ce381b5badd4ac2573358f Mon Sep 17 00:00:00 2001 From: Roger Zhang Date: Mon, 31 Aug 2026 15:38:45 -0700 Subject: [PATCH 5/5] test(init): guard that the hook runner wraps both cookiecutter call sites patched_hook_runner is a no-op outside a bundle, so dropping the wrapper left every test passing. Each guard asserts the context manager is entered, that cookiecutter runs inside it, and that it is exited. --- tests/unit/lib/cookiecutter/test_template.py | 17 ++++++++++++ tests/unit/lib/init/test_init.py | 27 ++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/tests/unit/lib/cookiecutter/test_template.py b/tests/unit/lib/cookiecutter/test_template.py index 22b8a667110..69ac81c3c38 100644 --- a/tests/unit/lib/cookiecutter/test_template.py +++ b/tests/unit/lib/cookiecutter/test_template.py @@ -8,6 +8,7 @@ PreprocessingError, PostprocessingError, ) +from cookiecutter import hooks as cookiecutter_hooks from cookiecutter.exceptions import RepositoryNotFound, UnknownRepoType @@ -102,6 +103,22 @@ def test_run_interactive_flows_throws_user_exception_if_something_wrong(self, mo mock_interactive_flow.run.assert_called_once_with({}) mock_plugin.interactive_flow.run.assert_called_once_with(self._ANY_INTERACTIVE_FLOW_CONTEXT) + @patch("samcli.lib.cookiecutter.template.patched_hook_runner") + @patch("samcli.lib.cookiecutter.template.cookiecutter") + def test_hook_runner_is_active_while_cookiecutter_runs(self, mock_cookiecutter, mock_hook_runner): + # Without this the wrapper could be dropped and every other test would still pass, because + # patched_hook_runner is a no-op unless running from a bundle. This is the sam pipeline init + # path, which was missed on the first pass at this fix. + events = [] + mock_hook_runner.return_value.__enter__.side_effect = lambda: events.append("enter") + mock_hook_runner.return_value.__exit__.side_effect = lambda *args: events.append("exit") + mock_cookiecutter.side_effect = lambda **kwargs: events.append("cookiecutter") + + Template(location=self._ANY_LOCATION).generate_project(context={}, output_dir=Mock()) + + mock_hook_runner.assert_called_once_with(cookiecutter_hooks) + self.assertEqual(events, ["enter", "cookiecutter", "exit"]) + @patch("samcli.lib.cookiecutter.template.cookiecutter") @patch("samcli.lib.cookiecutter.interactive_flow") @patch("samcli.lib.cookiecutter.processor") diff --git a/tests/unit/lib/init/test_init.py b/tests/unit/lib/init/test_init.py index d8e076c9a34..cff4a5b14fa 100644 --- a/tests/unit/lib/init/test_init.py +++ b/tests/unit/lib/init/test_init.py @@ -2,6 +2,7 @@ from unittest.mock import patch from pathlib import Path +from cookiecutter import hooks as cookiecutter_hooks from cookiecutter.exceptions import CookiecutterException, RepositoryNotFound from parameterized import parameterized @@ -22,6 +23,32 @@ def setUp(self): self.extra_context = {"project_name": "testing project", "runtime": self.runtime} self.template = RUNTIME_DEP_TEMPLATE_MAPPING["python"][0]["init_location"] + @patch("samcli.lib.init.cookiecutter") + @patch("samcli.lib.init._create_default_samconfig") + @patch("samcli.lib.init.patched_hook_runner") + def test_hook_runner_is_active_while_cookiecutter_runs( + self, hook_runner_mock, default_samconfig_mock, cookiecutter_patch + ): + # Without this the wrapper could be dropped and every other test would still pass, because + # patched_hook_runner is a no-op unless running from a bundle. + events = [] + hook_runner_mock.return_value.__enter__.side_effect = lambda: events.append("enter") + hook_runner_mock.return_value.__exit__.side_effect = lambda *args: events.append("exit") + cookiecutter_patch.side_effect = lambda **kwargs: events.append("cookiecutter") + + generate_project( + location=self.location, + runtime=self.runtime, + package_type=ZIP, + dependency_manager=self.dependency_manager, + output_dir=self.output_dir, + name=self.name, + no_input=self.no_input, + ) + + hook_runner_mock.assert_called_once_with(cookiecutter_hooks) + self.assertEqual(events, ["enter", "cookiecutter", "exit"]) + @patch("samcli.lib.init.cookiecutter") @patch("samcli.lib.init._create_default_samconfig") def test_init_successful(self, default_samconfig_mock, cookiecutter_patch):