Skip to content

fix(init): reject template hooks the installed SAM CLI cannot run - #9211

Merged
roger-zhangg merged 11 commits into
developfrom
reject-unsupported-template-hooks
Sep 2, 2026
Merged

roger-zhangg merged 11 commits into
developfrom
reject-unsupported-template-hooks

Conversation

@roger-zhangg

Copy link
Copy Markdown
Member

Which issue(s) does this change fix?

N/A

Why is this change necessary?

sam init --location (and sam pipeline init with a custom template) silently ignores a template's Python hooks on the packaged macOS and Linux SAM CLI.

Cookiecutter runs a Python hook as [sys.executable, hook.py]. In a PyInstaller bundle sys.executable is the sam executable rather than an interpreter, so the command becomes sam /tmp/xxxx.py. That path starts with /, which click treats as option-like: it prints help and exits 0. Cookiecutter reads 0 as success, so generation continues and the user gets a project missing whatever the hook was meant to do, with sam init reporting success.

Reproduced on a released binary:

exit code         : 0
hook actually ran : NO
stderr            : Command /var/folders/.../T/tmp48fx67ua.py not available

This does not affect the Windows installer, which ships an embedded CPython (sam.cmd runs python.exe -m samcli), so sys.executable there is a real interpreter and hooks run correctly. Verified on Windows CI: hook ran, sys.executable = ...\AWSSAMCLI\runtime\python.exe, exit 0. sys._MEIPASS is absent there, so this change is a no-op on Windows.

How does it address the issue?

Refuses rather than pretending to succeed, naming the hook and how to unblock:

Cannot run the template hook post_gen_project.py: this SAM CLI installation has no Python interpreter available to run it. To unblock, either re-install SAM CLI from pip, or remove the hook from the project.

A context manager wraps the cookiecutter() call at both call sites, active only when is_pyinstaller_bundle(). Three cookiecutter entry points are covered: run_script_with_context for pre_gen_project/post_gen_project, since it still knows the hook's real name rather than the temporary copy run_script receives; run_pre_prompt_hook up front, because cookiecutter otherwise replaces a pre_prompt failure with a message of its own that would hide the remedy; and run_script as a backstop.

The error subclasses cookiecutter's FailedHookException so cookiecutter still removes the partially generated project, leaving nothing behind to clean up before retrying.

What side effects does this change have?

Nothing that works today starts failing. Verified behaviour with the bundle simulated:

template result
no hooks/ directory succeeds, project generated
hooks/ with no matching hook file succeeds
hooks/post_gen_project.sh succeeds, hook ran
hooks/post_gen_project.py refused, no leftovers
hooks/pre_gen_project.py refused, no leftovers
hooks/pre_prompt.py refused, no leftovers

Cookiecutter never calls run_script when a template has no hook β€” run_hook returns early on find_hook β€” so a hookless --location template cannot be affected. Shell hooks are delegated untouched and keep working on POSIX. Windows and pip installs are unaffected.

Neither official template repository uses hooks: 0 hook files in aws-sam-cli-app-templates (6702 paths at the pinned commit) and 0 in aws-sam-cli-pipeline-init-templates, so the default sam init and sam pipeline init flows are untouched.

Also verified that removing the with block at either call site fails the corresponding new guard test and nothing else β€” without those guards the wrapper could be dropped silently, since it is a no-op outside a bundle.

make pr passes (9413 unit tests, 94.10% coverage).

Mandatory Checklist

PRs will only be reviewed after checklist is complete

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

Cookiecutter runs a Python hook with sys.executable. In the packaged macOS and
Linux SAM CLI that is the sam executable rather than an interpreter, so the hook
is not executed and cookiecutter is told it succeeded -- the project is generated
without whatever the hook was meant to do, and sam init exits 0.

Refuse instead, naming the hook and how to unblock. Templates without a Python
hook are unaffected, as is the Windows installer, which ships a real interpreter.
@roger-zhangg
roger-zhangg requested a review from a team as a code owner August 31, 2026 23:34
The packaged macOS and Linux SAM CLI now rejects Python hooks, so the assertion
that their output is captured only holds where hooks can run.
@bnusunny

bnusunny commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Thanks for digging into this β€” the diagnosis looks right to me, and I confirmed the patch targets against the pinned cookiecutter 2.7.1: all three attributes exist, main.py imports run_pre_prompt_hook as a module global so patching cookiecutter.main does take effect, and the .py gate matches cookiecutter's own dispatch in hooks.py:87.

Two things I'd like to see addressed before merge, both about how the refusal actually reaches the user.

1. On sam pipeline init, the refusal renders as a SAM CLI crash with a "file a bug" link.

Template.generate_project wraps it in samcli.lib.cookiecutter.exceptions.GenerateProjectFailedError, which subclasses plain Exception rather than UserException. interactive_init_flow.py:260 has no handler for it, so track_command's except Exception (samcli/lib/telemetry/metric.py:173) converts it to UnhandledException β€” that prints the message, then Traceback:, then "looks like a bug in SAM CLI" with a pre-filled Bug_report.md URL, and reports exit 255 to telemetry. sam init is fine because init_generator.py:45 converts InitErrorException into a UserException.

The gap is pre-existing for any cookiecutter failure on the pipeline path, but this change is the first to route an intentional, user-actionable error through it, so an expected refusal would invite bug reports. Converting at the pipeline call site (or having CookiecutterErrorException subclass UserException) would cover it.

2. pre_gen_project / post_gen_project print a raw traceback ahead of the message.

cookiecutter's run_hook_from_repo_dir calls logger.exception(...) before re-raising (hooks.py:172), and samcli/cli/main.py:25 calls logging.basicConfig(level=logging.INFO), so the cookiecutter logger propagates to a root stderr handler. The user sees Stopping generation because post_gen_project hook script didn't exit successfully plus a traceback, and only then the remedy β€” which reads as a crash rather than a deliberate refusal. The module already avoids this for pre_prompt (docstring lines 52-55); checking pre/post-gen up front the same way, or quieting the cookiecutter.hooks logger for the duration of the guard, would give those two the same clean output.

Happy to be corrected on either if you're seeing different output locally.

valerena
valerena previously approved these changes Sep 1, 2026
@bnusunny

bnusunny commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

One more, as a follow-up rather than something for this PR β€” the exit 0 you traced is worth fixing on its own.

SamCliCommandGroup.get_command logs Command %s not available (which is the exact stderr line in your repro) and returns None (samcli/cli/command.py:259-261). Click's MultiCommand.resolve_command then does:

if cmd is None and not ctx.resilient_parsing:
    if split_opt(cmd_name)[0]:
        self.parse_args(ctx, ctx.args)
    ctx.fail(f"No such command {cmd_name!r}.")

split_opt("/tmp/x.py") returns ("/", "tmp/x.py"), so the prefix is truthy and we never reach ctx.fail. ctx.args is empty by then, so no_args_is_help echoes help and calls ctx.exit() β€” exit 0. Any argument whose first character isn't alphanumeric takes that path, so sam /some/path and sam ./deploy.sh also report success today; only an alphanumeric typo like sam biuld correctly exits 2.

That means the silent-success problem isn't specific to cookiecutter β€” a script doing sam "$CMD" with a bad value gets exit 0. Letting click raise its UsageError here would fix it for every caller, and as a side effect cookiecutter's own FailedHookException would fire naturally (with its own cleanup), though with a much less helpful message than the one this PR adds. So I'd keep this change and treat the exit code as a separate fix β€” just flagging it so it doesn't get lost.

On sam pipeline init the refusal was wrapped by a catch-all into
GenerateProjectFailedError, which is not a UserException, so it reached the user
as an unhandled exception with a stack trace and a file-a-bug link. Convert it
there the way init_generator already does.

Cookiecutter 2.7.1 also logs a hook failure with logger.exception before
re-raising, putting a traceback ahead of the remedy, so quiet that logger for
the duration of the guard.
@roger-zhangg

Copy link
Copy Markdown
Member Author

Thanks for digging into this β€” the diagnosis looks right to me, and I confirmed the patch targets against the pinned cookiecutter 2.7.1: all three attributes exist, main.py imports run_pre_prompt_hook as a module global so patching cookiecutter.main does take effect, and the .py gate matches cookiecutter's own dispatch in hooks.py:87.

Two things I'd like to see addressed before merge, both about how the refusal actually reaches the user.

1. On sam pipeline init, the refusal renders as a SAM CLI crash with a "file a bug" link.

Template.generate_project wraps it in samcli.lib.cookiecutter.exceptions.GenerateProjectFailedError, which subclasses plain Exception rather than UserException. interactive_init_flow.py:260 has no handler for it, so track_command's except Exception (samcli/lib/telemetry/metric.py:173) converts it to UnhandledException β€” that prints the message, then Traceback:, then "looks like a bug in SAM CLI" with a pre-filled Bug_report.md URL, and reports exit 255 to telemetry. sam init is fine because init_generator.py:45 converts InitErrorException into a UserException.

The gap is pre-existing for any cookiecutter failure on the pipeline path, but this change is the first to route an intentional, user-actionable error through it, so an expected refusal would invite bug reports. Converting at the pipeline call site (or having CookiecutterErrorException subclass UserException) would cover it.

2. pre_gen_project / post_gen_project print a raw traceback ahead of the message.

cookiecutter's run_hook_from_repo_dir calls logger.exception(...) before re-raising (hooks.py:172), and samcli/cli/main.py:25 calls logging.basicConfig(level=logging.INFO), so the cookiecutter logger propagates to a root stderr handler. The user sees Stopping generation because post_gen_project hook script didn't exit successfully plus a traceback, and only then the remedy β€” which reads as a crash rather than a deliberate refusal. The module already avoids this for pre_prompt (docstring lines 52-55); checking pre/post-gen up front the same way, or quieting the cookiecutter.hooks logger for the duration of the guard, would give those two the same clean output.

Happy to be corrected on either if you're seeing different output locally.

Thanks for catching, fixed and updated on 1,2. The exit code change is at a much larger scale so not doing that in this PR.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 7ca0a8b..206dfdd
Files: 7
Comments: 2

Comment thread samcli/lib/utils/template_hooks.py Resolved
…overed

Raising the cookiecutter.hooks logger to CRITICAL silenced every hook log on a
bundle, losing which hook failed for shell hooks and all --debug hook
diagnostics. Filter on the in-flight exception instead.

Also split out a hook-free --location test, so that the project directory being
taken from cookiecutter's return value stays covered on the packaged CLI.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 7ca0a8b..ffe6d11
Files: 7
Comments: 3

Comment thread samcli/lib/cookiecutter/template.py Resolved
Comment thread samcli/lib/utils/template_hooks.py Resolved
The skip was keyed on SAM_CLI_DEV alone, which also covered the Windows
installer -- where hooks do run, since it ships an embedded interpreter rather
than a bundle. Narrow it so that platform keeps its coverage.

Also the hook lives in the template, not in the generated project.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 7ca0a8b..a6efe0e
Files: 7
Comments: 3

Comment thread samcli/lib/cookiecutter/template.py Resolved
Comment thread samcli/lib/utils/template_hooks.py Resolved
Comment thread samcli/lib/utils/template_hooks.py Resolved
The existing tests call the patched attributes directly, so they would keep
passing if a cookiecutter upgrade reached its hook runners some other way and the
guard silently stopped firing. Build a real template and assert cookiecutter()
itself refuses, for all three hooks including pre_prompt, which is dispatched
through cookiecutter.main rather than cookiecutter.hooks.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 7ca0a8b..133ebf9
Files: 7
Comments: 2

Comment thread samcli/lib/init/__init__.py Resolved
…e init

Only the pipeline path had the clause, so on sam init --location the remedy was
buried behind "An error occurred while generating this project : " -- with an
empty project name -- and telemetry recorded it as a generic cookiecutter
failure rather than this refusal.
They drove the real cookiecutter() with no config_file, so it read ~/.cookiecutterrc
-- whose default_context can decide project_name -- and wrote a replay file to
~/.cookiecutter_replay. Point it at a config inside the temp directory instead, so
these only fail when the guard stops firing.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 7ca0a8b..33198bb
Files: 7
Comments: 1

The backstop raises the same exception for a temporary copy, so a type-only
assertion would pass while the message degraded to tmpXXXX.py -- losing the
name that tells the user which hook to remove.
The wrappers narrowed the types to str, so a Path would have raised
AttributeError from endswith instead of refusing. Normalise with os.fspath.
@roger-zhangg
roger-zhangg added this pull request to the merge queue Sep 2, 2026
Merged via the queue into develop with commit 7db5788 Sep 2, 2026
53 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants