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
4 changes: 4 additions & 0 deletions samcli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
17 changes: 10 additions & 7 deletions samcli/lib/cookiecutter/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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__)

Expand Down Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion samcli/lib/init/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[GENERAL] The root cause fixed here applies to a second cookiecutter() call site that is left unpatched: Template.generate_project at samcli/lib/cookiecutter/template.py:170.

That path is reached by sam pipeline init, which lets the user point at an arbitrary template (CUSTOM_PIPELINE_TEMPLATE_SOURCE = "Custom Pipeline Template Location", samcli/commands/pipeline/init/interactive_init_flow.py:47). A custom pipeline template with a pre_gen_project.py / post_gen_project.py hook will hit the identical bundle behavior described in the PR: sys.executable is sam, click swallows the script path, exit code 0, hook silently skipped, project generated wrong. Since patched_hook_runner is already a no-op outside a bundle, wrapping the second call site is cheap and keeps the two generation paths consistent:

from cookiecutter import hooks as cookiecutter_hooks

from samcli.lib.utils.hook_script import patched_hook_runner

...
           with patched_hook_runner(cookiecutter_hooks):
               cookiecutter(
                   template=self._location,
                   output_dir=output_dir,
                   no_input=True,
                   extra_context=context,
                   overwrite_if_exists=True,
               )

If leaving pipeline templates out is deliberate (e.g. scoping this PR to sam init), it would help to say so, since the same silent-wrong-output failure applies there.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Valid, fixed in b137693 — the omission was an oversight, not a scoping decision.

Verified both halves of the claim before changing anything: the second cookiecutter() call is at samcli/lib/cookiecutter/template.py:170, and sam pipeline init does reach it with a user-supplied location (CUSTOM_PIPELINE_TEMPLATE_SOURCE is offered at interactive_init_flow.py:74 and branched on at :78). So a custom pipeline template with a .py hook would hit the identical silent skip.

Template.generate_project now wraps the call the same way. As you note it costs nothing outside a bundle since patched_hook_runner returns immediately, and it keeps the two generation paths consistent — which matters here because the failure mode is silent wrong output rather than an error.

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
Expand Down
132 changes: 132 additions & 0 deletions samcli/lib/utils/hook_script.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"""
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, isolate_library_paths_for_subprocess

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[GENERAL] The rationale for preferring a system interpreter is inverted, and the resulting order is the one that diverges most from a pip install.

The comment says a system python "gives hooks the same environment they get from a pip install, rather than this bundle's Python and dependencies." Under a pip install, sys.executable is the interpreter that has SAM CLI's dependencies importable — PyYAML~=6.0 is a direct dependency (pyproject.toml:41), and jinja2 comes in with cookiecutter. So a hook containing import yaml or import jinja2 succeeds today for pip users, and would also succeed on the re-launch fallback (which runs under the bundle's sys.path), but fails against a bare /usr/bin/python3 — the path this code prefers.

Net effect: the preferred branch is the only one of the three that cannot import SAM's dependencies, and the failure surfaces as a ModuleNotFoundError inside a FailedHookException for custom --location templates. Either make the re-launch path the primary and the system interpreter the fallback, or keep the current order and correct the comment to say the system interpreter is preferred for isolation, not fidelity, so the next reader does not rely on a guarantee that is not there.

# 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


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])
# 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()
# 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)


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)"],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[BUG] The probe accepts any Python 3.x, so a system interpreter older than the one SAM CLI supports will be preferred over the bundled one.

sys.version_info[0] == 3 is true for 3.0 through 3.6. That matters specifically on the platforms the native installer exists to serve: CentOS/RHEL 7 ships /usr/bin/python3 as 3.6.8, Amazon Linux 2 as 3.7.x. On those hosts find_system_interpreter() returns the old interpreter and the hook runs on it, even though the bundle ships 3.11 and pyproject.toml:10 declares requires-python = ">=3.10". A hook using :=, match, or f-string = specifiers then dies with a SyntaxError that a pip-installed sam would never produce.

This is also where not sharing code with the existing probe bites: _get_python_command_name() (samcli/hook_packages/terraform/hooks/prepare/enrich.py:691) borrows this candidate order but enforces a 3.7+ floor via PYTHON_VERSION_REGEX, so the two probes now disagree on what counts as usable Python. Extracting one helper would keep the floor in one place.

completed = subprocess.run(
   [path, "-c", "import sys; sys.exit(0 if sys.version_info >= (3, 8) else 1)"],
   ...
)

(3.8 is the conservative floor; (3, 10) would match what the project requires of itself.)

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
2 changes: 1 addition & 1 deletion tests/integration/init/test_init_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""


Expand Down
Loading
Loading