Source code for cookiecutter.hooks

"""Functions for discovering and executing various cookiecutter hooks."""

from __future__ import annotations

import errno
import logging
import os
import subprocess
import sys
import tempfile
from typing import TYPE_CHECKING, Any

from jinja2.exceptions import UndefinedError

from cookiecutter import utils
from cookiecutter.exceptions import FailedHookException
from cookiecutter.utils import (
    create_env_with_context,
    create_tmp_repo_dir,
    rmtree,
    work_in,
)

if TYPE_CHECKING:
    from pathlib import Path

logger = logging.getLogger(__name__)

_HOOKS = [
    'pre_prompt',
    'pre_gen_project',
    'post_gen_project',
]
EXIT_SUCCESS = 0


[docs] def valid_hook(hook_file: str, hook_name: str) -> bool: """Determine if a hook file is valid. :param hook_file: The hook file to consider for validity :param hook_name: The hook to find :return: The hook file validity """ filename = os.path.basename(hook_file) basename = os.path.splitext(filename)[0] matching_hook = basename == hook_name supported_hook = basename in _HOOKS backup_file = filename.endswith('~') return matching_hook and supported_hook and not backup_file
[docs] def find_hook(hook_name: str, hooks_dir: str = 'hooks') -> list[str] | None: """Return a dict of all hook scripts provided. Must be called with the project template as the current working directory. Dict's key will be the hook/script's name, without extension, while values will be the absolute path to the script. Missing scripts will not be included in the returned dict. :param hook_name: The hook to find :param hooks_dir: The hook directory in the template :return: The absolute path to the hook script or None """ logger.debug('hooks_dir is %s', os.path.abspath(hooks_dir)) if not os.path.isdir(hooks_dir): logger.debug('No hooks/dir in template_dir') return None scripts = [ os.path.abspath(os.path.join(hooks_dir, hook_file)) for hook_file in os.listdir(hooks_dir) if valid_hook(hook_file, hook_name) ] if len(scripts) == 0: return None return scripts
[docs] def run_script(script_path: str, cwd: Path | str = '.') -> None: """Execute a script from a working directory. :param script_path: Absolute path to the script to run. :param cwd: The directory to run the script from. """ run_thru_shell = sys.platform.startswith('win') if script_path.endswith('.py'): script_command = [sys.executable, script_path] else: script_command = [script_path] utils.make_executable(script_path) try: proc = subprocess.Popen(script_command, shell=run_thru_shell, cwd=cwd) # nosec exit_status = proc.wait() if exit_status != EXIT_SUCCESS: raise FailedHookException( f'Hook script failed (exit status: {exit_status})' ) except OSError as err: if err.errno == errno.ENOEXEC: raise FailedHookException( 'Hook script failed, might be an empty file or missing a shebang' ) from err raise FailedHookException(f'Hook script failed (error: {err})') from err
[docs] def run_script_with_context( script_path: Path | str, cwd: Path | str, context: dict[str, Any] ) -> None: """Execute a script after rendering it with Jinja. :param script_path: Absolute path to the script to run. :param cwd: The directory to run the script from. :param context: Cookiecutter project template context. """ _, extension = os.path.splitext(script_path) with open(script_path, encoding='utf-8') as file: contents = file.read() with tempfile.NamedTemporaryFile(delete=False, mode='wb', suffix=extension) as temp: env = create_env_with_context(context) template = env.from_string(contents) output = template.render(**context) temp.write(output.encode('utf-8')) run_script(temp.name, cwd)
[docs] def run_hook(hook_name: str, project_dir: Path | str, context: dict[str, Any]) -> None: """ Try to find and execute a hook from the specified project directory. :param hook_name: The hook to execute. :param project_dir: The directory to execute the script from. :param context: Cookiecutter project context. """ scripts = find_hook(hook_name) if not scripts: logger.debug('No %s hook found', hook_name) return logger.debug('Running hook %s', hook_name) for script in scripts: run_script_with_context(script, project_dir, context)
[docs] def run_hook_from_repo_dir( repo_dir: Path | str, hook_name: str, project_dir: Path | str, context: dict[str, Any], delete_project_on_failure: bool, ) -> None: """Run hook from repo directory, clean project directory if hook fails. :param repo_dir: Project template input directory. :param hook_name: The hook to execute. :param project_dir: The directory to execute the script from. :param context: Cookiecutter project context. :param delete_project_on_failure: Delete the project directory on hook failure? """ with work_in(repo_dir): try: run_hook(hook_name, project_dir, context) except ( FailedHookException, UndefinedError, ): if delete_project_on_failure: rmtree(project_dir) logger.error( "Stopping generation because %s hook " "script didn't exit successfully", hook_name, ) raise
[docs] def run_pre_prompt_hook(repo_dir: Path | str) -> Path | str: """Run pre_prompt hook from repo directory. :param repo_dir: Project template input directory. """ # Check if we have a valid pre_prompt script with work_in(repo_dir): scripts = find_hook('pre_prompt') if not scripts: return repo_dir # Create a temporary directory repo_dir = create_tmp_repo_dir(repo_dir) with work_in(repo_dir): scripts = find_hook('pre_prompt') or [] for script in scripts: try: run_script(script, str(repo_dir)) except FailedHookException as e: # noqa: PERF203 raise FailedHookException('Pre-Prompt Hook script failed') from e return repo_dir