-
Notifications
You must be signed in to change notification settings - Fork 1.2k
fix(init): run cookiecutter template hooks from the packaged binary #9206
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
8d01a35
b137693
7ec31c3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, Net effect: the preferred branch is the only one of the three that cannot import SAM's dependencies, and the failure surfaces as a |
||
| # 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)"], | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
This is also where not sharing code with the existing probe bites: completed = subprocess.run(
[path, "-c", "import sys; sys.exit(0 if sys.version_info >= (3, 8) else 1)"],
...
)(3.8 is the conservative floor; |
||
| 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 | ||
There was a problem hiding this comment.
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_projectatsamcli/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 apre_gen_project.py/post_gen_project.pyhook will hit the identical bundle behavior described in the PR:sys.executableissam, click swallows the script path, exit code 0, hook silently skipped, project generated wrong. Sincepatched_hook_runneris already a no-op outside a bundle, wrapping the second call site is cheap and keeps the two generation paths consistent: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.There was a problem hiding this comment.
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 atsamcli/lib/cookiecutter/template.py:170, andsam pipeline initdoes reach it with a user-supplied location (CUSTOM_PIPELINE_TEMPLATE_SOURCEis offered atinteractive_init_flow.py:74and branched on at:78). So a custom pipeline template with a.pyhook would hit the identical silent skip.Template.generate_projectnow wraps the call the same way. As you note it costs nothing outside a bundle sincepatched_hook_runnerreturns immediately, and it keeps the two generation paths consistent — which matters here because the failure mode is silent wrong output rather than an error.