Project Setup

Post-Generation Hooks in Python CLI Templates

Use cookiecutter pre- and post-generation hooks in CLI templates: validate answers, remove unused files, run git init and uv lock, print next steps, and test hooks.

Updated

A template can only do so much with Jinja. Some work has to happen as code: rejecting a command name that would shadow a system tool, deleting the Dockerfile when the user said they did not want one, creating the git repository and lockfile, and telling the user what to do next. Cookiecutter runs two optional Python scripts around generation for exactly this — pre_gen_project.py before any files are written, and post_gen_project.py inside the freshly generated project. This guide covers what belongs in each hook for a CLI template, how to write them so they are fast and safe on other people's machines, and how to test them. It builds on building a cookiecutter template for Typer CLIs and belongs to the CLI project scaffolding topic.

Prerequisites

  • A working cookiecutter template with a hooks/ directory next to cookiecutter.json.
  • git and uv available on the machines that will generate projects (the hooks check for them and degrade gracefully when they are missing).

When hooks run

When template hooks run The order of events when cookiecutter generates a project: prompts, the pre-generation hook, rendering files, then the post-generation hook in the new directory. When template hooks run Prompts answers collected 1 pre_gen_project validate answers 2 Render Jinja over the tree 3 post_gen_project cwd = new project 4 a non-zero exit from either hook aborts generation and removes the output Validate early in the pre hook; do setup work in the post hook.

Cookiecutter collects answers, runs hooks/pre_gen_project.py (rendered through Jinja first, so it can read the answers), renders the template tree, then runs hooks/post_gen_project.py with the current working directory set to the new project. If either hook exits non-zero, cookiecutter stops and deletes whatever it generated, so a failed validation never leaves a half-made project behind.

Because hooks are rendered as Jinja templates, answers appear in them as literal values: "{{ cookiecutter.command_name }}" inside a Python string becomes "dh" before the script runs.

The recipe: validating answers before anything is written

The pre-generation hook is the place to reject answers that would produce a broken project:

# hooks/pre_gen_project.py
import keyword
import re
import shutil
import sys

package = "{{ cookiecutter.package_name }}"
command = "{{ cookiecutter.command_name }}"

errors = []
if not re.fullmatch(r"[a-z][a-z0-9_]*", package) or keyword.iskeyword(package):
    errors.append(f"package name {package!r} is not a valid Python identifier")
if not re.fullmatch(r"[a-z][a-z0-9-]*", command):
    errors.append(f"command {command!r} should be lowercase letters, digits and dashes")
if shutil.which(command):
    errors.append(f"command {command!r} already exists on this machine ({shutil.which(command)}); "
                  "users would get the wrong program")

if errors:
    for e in errors:
        print(f"error: {e}", file=sys.stderr)
    sys.exit(1)

The shadowing check is specific to CLI templates and surprisingly valuable: a tool named test, deploy or sync collides with existing commands on many machines, and the resulting confusion ("why does test --help print nothing?") is miserable to debug later.

The recipe: finishing the project after rendering

The post-generation hook handles everything that cannot be expressed as a file: removing optional parts, initialising version control, locking dependencies and printing next steps.

# hooks/post_gen_project.py
import shutil
import subprocess
import sys
from pathlib import Path

INCLUDE_DOCKER = "{{ cookiecutter.include_docker }}" == "yes"
PROJECT = "{{ cookiecutter.project_slug }}"
COMMAND = "{{ cookiecutter.command_name }}"


def remove(*paths: str) -> None:
    for p in map(Path, paths):
        if p.is_dir():
            shutil.rmtree(p)
        elif p.exists():
            p.unlink()


def run(*cmd: str) -> bool:
    """Run a helper command; report and continue if it is missing or fails."""
    if shutil.which(cmd[0]) is None:
        print(f"note: {cmd[0]} not found; skipped: {' '.join(cmd)}", file=sys.stderr)
        return False
    result = subprocess.run(cmd, capture_output=True, text=True)
    if result.returncode != 0:
        print(f"note: '{' '.join(cmd)}' failed:\n{result.stderr.strip()}", file=sys.stderr)
        return False
    return True


if not INCLUDE_DOCKER:
    remove("Dockerfile", ".dockerignore")

locked = run("uv", "lock")
if run("git", "init", "-q", "-b", "main"):
    run("git", "add", "-A")
    run("git", "commit", "-q", "-m", f"Initial {PROJECT} from template")

print(f"""
Created {PROJECT}. Next steps:

  cd {PROJECT}
  {"uv sync" if locked else "uv lock && uv sync"}
  uv run {COMMAND} --help
  uv run pytest
""")

Choosing template logic or a hook

Conditional files: template logic or hook? A decision for optional parts of a generated project: small differences belong in Jinja conditionals, whole optional files belong in a post-generation hook that removes them. Conditional files: template logic or hook? How big is the optional part? A few lines in a shared file {% if %} in Jinja stays readable Whole files or directories Remove in the hook e.g. docs/ or Dockerfile Filenames can be templated too, but an empty-named file is harder to reason about than a deletion.

Small optional differences — an extra dependency line, a conditional section in the README — read best as {% if %} blocks in the template. Whole optional files and directories are clearer to delete in the hook: a template tree with conditionally empty file names is hard to follow, whereas remove("Dockerfile") says exactly what happens.

Principles for hooks on other people's machines

What a post-generation hook should do Appropriate tasks for a post-generation hook in a CLI template, and tasks it should leave to the user. What a post-generation hook should do Good hook tasks Delete files for options not chosen git init and an initial commit uv lock to create the lockfile Print the next three commands to run Leave to the user Creating remote repositories Installing tools globally Anything needing credentials Long downloads without asking Hooks run on someone else's machine; keep them fast, local and reversible.
  • Degrade, do not fail, on missing tools. A developer without uv installed should still get a project, plus a note saying which step was skipped. Only validation failures in the pre hook should abort generation.
  • Stay local and fast. uv lock downloads metadata, which is usually acceptable; anything slower, or anything that needs credentials — creating a GitHub repository, registering a PyPI name — belongs in documentation or a separate command the user chooses to run.
  • Use only the standard library. Hooks run in cookiecutter's environment, not the new project's. Importing a third-party package in a hook fails for anyone who does not happen to have it installed.
  • Never touch anything outside the new directory. The hook's working directory is the generated project; keep it there.

Debugging hooks

Hook failures are confusing because the hook runs as a rendered copy of your script in a temporary location, and cookiecutter deletes the half-generated output when it fails. A few habits make them tractable:

  • Run cookiecutter with --verbose. It prints the rendered hook path and the command used to run it, so you can see exactly what executed.
  • Keep failed output while debugging. --keep-project-on-failure leaves the generated directory in place after a post-hook failure, so you can inspect what was rendered and rerun the hook by hand inside it.
  • Check the rendered script first. Most hook bugs are Jinja bugs: a variable that renders to an empty string, or a quote inside an answer that breaks the Python string it was substituted into. Rendering answers through Python literals — PROJECT = {{ cookiecutter.project_slug | tojson }} — avoids the quoting problem entirely, because tojson produces a valid Python string literal for any value.
  • Remember Windows. Hooks run with the interpreter that runs cookiecutter, on whatever platform the user has. Use pathlib and subprocess argument lists rather than shell strings, so the same hook works in PowerShell and bash alike.

UX considerations

  • End with the next three commands. The single most useful thing a post hook can do is tell the user how to run what they just created. Tailor it to what actually succeeded — as the locked flag does above.
  • Explain validation failures in the user's terms. "command 'test' already exists on this machine (/usr/bin/test)" explains the problem and implies the fix.
  • Make the initial commit meaningful. A first commit containing exactly what the template produced makes it easy to see later which changes the team made on top.
  • Keep output short. Notes about skipped steps on stderr, the next-steps block on stdout, nothing else.

Testing the behaviour

Hooks run as part of cookiecutter(...), so the same generate-and-inspect tests from the template guide exercise them. Add cases for each branch:

# tests/test_hooks.py
import shutil
import subprocess
from pathlib import Path

import pytest
from cookiecutter.exceptions import FailedHookException
from cookiecutter.main import cookiecutter

TEMPLATE = str(Path(__file__).parent.parent)


def generate(tmp_path, **context) -> Path:
    return Path(cookiecutter(TEMPLATE, no_input=True, output_dir=str(tmp_path),
                             extra_context=context))


def test_docker_files_removed_when_not_wanted(tmp_path):
    project = generate(tmp_path, project_name="Alpha Tool", include_docker="no")
    assert not (project / "Dockerfile").exists()


def test_git_repository_and_lockfile_created(tmp_path):
    if not (shutil.which("git") and shutil.which("uv")):
        pytest.skip("needs git and uv")
    project = generate(tmp_path, project_name="Beta Tool")
    assert (project / "uv.lock").exists()
    log = subprocess.run(["git", "log", "--oneline"], cwd=project, capture_output=True, text=True)
    assert "Initial beta-tool from template" in log.stdout


def test_shadowing_command_is_rejected(tmp_path):
    with pytest.raises(FailedHookException):
        generate(tmp_path, project_name="Gamma", command_name="ls")
    assert list(tmp_path.iterdir()) == []            # nothing left behind


def test_invalid_package_name_is_rejected(tmp_path):
    with pytest.raises(FailedHookException):
        generate(tmp_path, project_name="Class", package_name="class")

The "nothing left behind" assertion verifies cookiecutter's cleanup on hook failure — the behaviour users rely on when they mistype an answer. Run these tests in the template's CI; hooks are exactly the part of a template that breaks silently when a tool changes its command-line flags.

Conclusion

Hooks are where a template stops being a pile of files and becomes a tool. Use the pre-generation hook to reject answers that would produce broken or confusing projects — invalid identifiers, commands that shadow existing programs — and the post-generation hook to remove unused files, lock dependencies, create the first commit and print the next steps. Keep hooks fast, local and standard-library-only, let them degrade gracefully when tools are missing, and test every branch by generating projects in pytest.

Frequently asked questions

Can hooks be shell scripts instead of Python?

Yes — pre_gen_project.sh works on POSIX systems. Python hooks are the better choice for CLI templates because they run identically on Windows, macOS and Linux, and your template's users are Python developers anyway.

How do I pass data from the pre hook to the post hook?

There is no direct channel. Anything both hooks need should be a cookiecutter variable, possibly a private one (prefixed with an underscore) computed from other answers in cookiecutter.json.

Should the post hook run the tests?

It can, but it slows generation and makes it fail for reasons unrelated to the template (a flaky network during uv sync). Printing uv run pytest as a next step is usually better; the template's own CI already proves generated projects pass.

Does Copier have the same hooks?

Copier calls them tasks, defined in copier.yml as commands run after copying, and adds migrations for template updates. The same principles apply: validate early, keep tasks local and fast, and test them.