Source code for cookiecutter.generate

"""Functions for generating a project from a project template."""

import fnmatch
import json
import logging
import os
import shutil
import warnings
from collections import OrderedDict
from pathlib import Path

from binaryornot.check import is_binary
from jinja2 import Environment, FileSystemLoader
from jinja2.exceptions import TemplateSyntaxError, UndefinedError

from cookiecutter.exceptions import (
    ContextDecodingException,
    OutputDirExistsException,
    UndefinedVariableInTemplate,
)
from cookiecutter.find import find_template
from cookiecutter.hooks import run_hook_from_repo_dir
from cookiecutter.utils import (
    create_env_with_context,
    make_sure_path_exists,
    rmtree,
    work_in,
)

logger = logging.getLogger(__name__)


[docs] def is_copy_only_path(path, context): """Check whether the given `path` should only be copied and not rendered. Returns True if `path` matches a pattern in the given `context` dict, otherwise False. :param path: A file-system path referring to a file or dir that should be rendered or just copied. :param context: cookiecutter context. """ try: for dont_render in context['cookiecutter']['_copy_without_render']: if fnmatch.fnmatch(path, dont_render): return True except KeyError: return False return False
[docs] def apply_overwrites_to_context( context, overwrite_context, *, in_dictionary_variable=False ): """Modify the given context in place based on the overwrite_context.""" for variable, overwrite in overwrite_context.items(): if variable not in context: if not in_dictionary_variable: # We are dealing with a new variable on first level, ignore continue # We are dealing with a new dictionary variable in a deeper level context[variable] = overwrite context_value = context[variable] if isinstance(context_value, list): if in_dictionary_variable: context[variable] = overwrite continue if isinstance(overwrite, list): # We are dealing with a multichoice variable # Let's confirm all choices are valid for the given context if set(overwrite).issubset(set(context_value)): context[variable] = overwrite else: raise ValueError( f"{overwrite} provided for multi-choice variable " f"{variable}, but valid choices are {context_value}" ) else: # We are dealing with a choice variable if overwrite in context_value: # This overwrite is actually valid for the given context # Let's set it as default (by definition first item in list) # see ``cookiecutter.prompt.prompt_choice_for_config`` context_value.remove(overwrite) context_value.insert(0, overwrite) else: raise ValueError( f"{overwrite} provided for choice variable " f"{variable}, but the choices are {context_value}." ) elif isinstance(context_value, dict) and isinstance(overwrite, dict): # Partially overwrite some keys in original dict apply_overwrites_to_context( context_value, overwrite, in_dictionary_variable=True ) context[variable] = context_value else: # Simply overwrite the value for this variable context[variable] = overwrite
[docs] def generate_context( context_file='cookiecutter.json', default_context=None, extra_context=None ): """Generate the context for a Cookiecutter project template. Loads the JSON file as a Python object, with key being the JSON filename. :param context_file: JSON file containing key/value pairs for populating the cookiecutter's variables. :param default_context: Dictionary containing config to take into account. :param extra_context: Dictionary containing configuration overrides """ context = OrderedDict([]) try: with open(context_file, encoding='utf-8') as file_handle: obj = json.load(file_handle, object_pairs_hook=OrderedDict) except ValueError as e: # JSON decoding error. Let's throw a new exception that is more # friendly for the developer or user. full_fpath = os.path.abspath(context_file) json_exc_message = str(e) our_exc_message = ( f"JSON decoding error while loading '{full_fpath}'. " f"Decoding error details: '{json_exc_message}'" ) raise ContextDecodingException(our_exc_message) from e # Add the Python object to the context dictionary file_name = os.path.split(context_file)[1] file_stem = file_name.split('.')[0] context[file_stem] = obj # Overwrite context variable defaults with the default context from the # user's global config, if available if default_context: try: apply_overwrites_to_context(obj, default_context) except ValueError as error: warnings.warn(f"Invalid default received: {error}") if extra_context: apply_overwrites_to_context(obj, extra_context) logger.debug('Context generated is %s', context) return context
[docs] def generate_file(project_dir, infile, context, env, skip_if_file_exists=False): """Render filename of infile as name of outfile, handle infile correctly. Dealing with infile appropriately: a. If infile is a binary file, copy it over without rendering. b. If infile is a text file, render its contents and write the rendered infile to outfile. Precondition: When calling `generate_file()`, the root template dir must be the current working directory. Using `utils.work_in()` is the recommended way to perform this directory change. :param project_dir: Absolute path to the resulting generated project. :param infile: Input file to generate the file from. Relative to the root template dir. :param context: Dict for populating the cookiecutter's variables. :param env: Jinja2 template execution environment. """ logger.debug('Processing file %s', infile) # Render the path to the output file (not including the root project dir) outfile_tmpl = env.from_string(infile) outfile = os.path.join(project_dir, outfile_tmpl.render(**context)) file_name_is_empty = os.path.isdir(outfile) if file_name_is_empty: logger.debug('The resulting file name is empty: %s', outfile) return if skip_if_file_exists and os.path.exists(outfile): logger.debug('The resulting file already exists: %s', outfile) return logger.debug('Created file at %s', outfile) # Just copy over binary files. Don't render. logger.debug("Check %s to see if it's a binary", infile) if is_binary(infile): logger.debug('Copying binary %s to %s without rendering', infile, outfile) shutil.copyfile(infile, outfile) shutil.copymode(infile, outfile) return # Force fwd slashes on Windows for get_template # This is a by-design Jinja issue infile_fwd_slashes = infile.replace(os.path.sep, '/') # Render the file try: tmpl = env.get_template(infile_fwd_slashes) except TemplateSyntaxError as exception: # Disable translated so that printed exception contains verbose # information about syntax error location exception.translated = False raise rendered_file = tmpl.render(**context) if context['cookiecutter'].get('_new_lines', False): # Use `_new_lines` from context, if configured. newline = context['cookiecutter']['_new_lines'] logger.debug('Using configured newline character %s', repr(newline)) else: # Detect original file newline to output the rendered file. # Note that newlines can be a tuple if file contains mixed line endings. # In this case, we pick the first line ending we detected. with open(infile, encoding='utf-8') as rd: rd.readline() # Read only the first line to load a 'newlines' value. newline = rd.newlines[0] if isinstance(rd.newlines, tuple) else rd.newlines logger.debug('Using detected newline character %s', repr(newline)) logger.debug('Writing contents to file %s', outfile) with open(outfile, 'w', encoding='utf-8', newline=newline) as fh: fh.write(rendered_file) # Apply file permissions to output file shutil.copymode(infile, outfile)
[docs] def render_and_create_dir( dirname: str, context: dict, output_dir: "os.PathLike[str]", environment: Environment, overwrite_if_exists: bool = False, ): """Render name of a directory, create the directory, return its path.""" name_tmpl = environment.from_string(dirname) rendered_dirname = name_tmpl.render(**context) dir_to_create = Path(output_dir, rendered_dirname) logger.debug( 'Rendered dir %s must exist in output_dir %s', dir_to_create, output_dir ) output_dir_exists = dir_to_create.exists() if output_dir_exists: if overwrite_if_exists: logger.debug( 'Output directory %s already exists, overwriting it', dir_to_create ) else: msg = f'Error: "{dir_to_create}" directory already exists' raise OutputDirExistsException(msg) else: make_sure_path_exists(dir_to_create) return dir_to_create, not output_dir_exists
def _run_hook_from_repo_dir( repo_dir, hook_name, project_dir, context, delete_project_on_failure ): """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? """ warnings.warn( "The '_run_hook_from_repo_dir' function is deprecated, " "use 'cookiecutter.hooks.run_hook_from_repo_dir' instead", DeprecationWarning, 2, ) run_hook_from_repo_dir( repo_dir, hook_name, project_dir, context, delete_project_on_failure )
[docs] def generate_files( repo_dir, context=None, output_dir='.', overwrite_if_exists=False, skip_if_file_exists=False, accept_hooks=True, keep_project_on_failure=False, ): """Render the templates and saves them to files. :param repo_dir: Project template input directory. :param context: Dict for populating the template's variables. :param output_dir: Where to output the generated project dir into. :param overwrite_if_exists: Overwrite the contents of the output directory if it exists. :param skip_if_file_exists: Skip the files in the corresponding directories if they already exist :param accept_hooks: Accept pre and post hooks if set to `True`. :param keep_project_on_failure: If `True` keep generated project directory even when generation fails """ context = context or OrderedDict([]) env = create_env_with_context(context) template_dir = find_template(repo_dir, env) logger.debug('Generating project from %s...', template_dir) unrendered_dir = os.path.split(template_dir)[1] try: project_dir, output_directory_created = render_and_create_dir( unrendered_dir, context, output_dir, env, overwrite_if_exists ) except UndefinedError as err: msg = f"Unable to create project directory '{unrendered_dir}'" raise UndefinedVariableInTemplate(msg, err, context) from err # We want the Jinja path and the OS paths to match. Consequently, we'll: # + CD to the template folder # + Set Jinja's path to '.' # # In order to build our files to the correct folder(s), we'll use an # absolute path for the target folder (project_dir) project_dir = os.path.abspath(project_dir) logger.debug('Project directory is %s', project_dir) # if we created the output directory, then it's ok to remove it # if rendering fails delete_project_on_failure = output_directory_created and not keep_project_on_failure if accept_hooks: run_hook_from_repo_dir( repo_dir, 'pre_gen_project', project_dir, context, delete_project_on_failure ) with work_in(template_dir): env.loader = FileSystemLoader(['.', '../templates']) for root, dirs, files in os.walk('.'): # We must separate the two types of dirs into different lists. # The reason is that we don't want ``os.walk`` to go through the # unrendered directories, since they will just be copied. copy_dirs = [] render_dirs = [] for d in dirs: d_ = os.path.normpath(os.path.join(root, d)) # We check the full path, because that's how it can be # specified in the ``_copy_without_render`` setting, but # we store just the dir name if is_copy_only_path(d_, context): logger.debug('Found copy only path %s', d) copy_dirs.append(d) else: render_dirs.append(d) for copy_dir in copy_dirs: indir = os.path.normpath(os.path.join(root, copy_dir)) outdir = os.path.normpath(os.path.join(project_dir, indir)) outdir = env.from_string(outdir).render(**context) logger.debug('Copying dir %s to %s without rendering', indir, outdir) # The outdir is not the root dir, it is the dir which marked as copy # only in the config file. If the program hits this line, which means # the overwrite_if_exists = True, and root dir exists if os.path.isdir(outdir): shutil.rmtree(outdir) shutil.copytree(indir, outdir) # We mutate ``dirs``, because we only want to go through these dirs # recursively dirs[:] = render_dirs for d in dirs: unrendered_dir = os.path.join(project_dir, root, d) try: render_and_create_dir( unrendered_dir, context, output_dir, env, overwrite_if_exists ) except UndefinedError as err: if delete_project_on_failure: rmtree(project_dir) _dir = os.path.relpath(unrendered_dir, output_dir) msg = f"Unable to create directory '{_dir}'" raise UndefinedVariableInTemplate(msg, err, context) from err for f in files: infile = os.path.normpath(os.path.join(root, f)) if is_copy_only_path(infile, context): outfile_tmpl = env.from_string(infile) outfile_rendered = outfile_tmpl.render(**context) outfile = os.path.join(project_dir, outfile_rendered) logger.debug( 'Copying file %s to %s without rendering', infile, outfile ) shutil.copyfile(infile, outfile) shutil.copymode(infile, outfile) continue try: generate_file( project_dir, infile, context, env, skip_if_file_exists ) except UndefinedError as err: if delete_project_on_failure: rmtree(project_dir) msg = f"Unable to create file '{infile}'" raise UndefinedVariableInTemplate(msg, err, context) from err if accept_hooks: run_hook_from_repo_dir( repo_dir, 'post_gen_project', project_dir, context, delete_project_on_failure, ) return project_dir