diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a84a59992..a63151166 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,6 +4,7 @@ on: push: branches: - master + - main tags: - "*" pull_request: diff --git a/cookiecutter/context.py b/cookiecutter/context.py new file mode 100644 index 000000000..389cda653 --- /dev/null +++ b/cookiecutter/context.py @@ -0,0 +1,610 @@ +# -*- coding: utf-8 -*- +# flake8: noqa +""" +cookiecutter.context +-------------------- + +Process the version 2 cookiecutter context (previsously loaded via +cookiecutter.json) and handle any user input that might be associated with +initializing the settings defined in the 'variables' OrderedDict part of the +context. + +This module produces a dictionary used later by the jinja2 template engine to +generate files. + +Based on the source code written by @hackebrot see: +https://github.com/audreyr/cookiecutter/pull/848 +https://github.com/hackebrot/cookiecutter/tree/new-context-format + +""" + +import logging +import collections +import json +import re +import shutil + +import click +from jinja2 import Environment + +logger = logging.getLogger(__name__) + +DEFAULT_PROMPT = 'Please enter a value for "{variable.name}"' + +VALID_TYPES = [ + 'boolean', + 'yes_no', + 'int', + 'float', + 'uuid', + 'json', + 'string', +] + +SET_OF_REQUIRED_FIELDS = { + 'name', + 'cookiecutter_version', + 'variables', +} + +REGEX_COMPILE_FLAGS = { + 'ascii': re.ASCII, + 'debug': re.DEBUG, + 'ignorecase': re.IGNORECASE, + 'locale': re.LOCALE, + 'mulitline': re.MULTILINE, + 'dotall': re.DOTALL, + 'verbose': re.VERBOSE, +} + + +def context_is_version_2(cookiecutter_context): + """ + Return True if the cookiecutter_context meets the current requirements for + a version 2 cookiecutter.json file format. + """ + # This really is not sufficient since a v1 context could define each of + # these fields; perhaps a more thorough test would be to also check if the + # 'variables' field was defined as a list of OrderedDict items. + if (cookiecutter_context.keys() & SET_OF_REQUIRED_FIELDS) == SET_OF_REQUIRED_FIELDS: + return True + else: + return False + + +def prompt_string(variable, default): + return click.prompt( + variable.prompt, + default=default, + hide_input=variable.hide_input, + type=click.STRING, + ) + + +def prompt_boolean(variable, default): + return click.prompt( + variable.prompt, + default=default, + hide_input=variable.hide_input, + type=click.BOOL, + ) + + +def prompt_int(variable, default): + return click.prompt( + variable.prompt, + default=default, + hide_input=variable.hide_input, + type=click.INT, + ) + + +def prompt_float(variable, default): + return click.prompt( + variable.prompt, + default=default, + hide_input=variable.hide_input, + type=click.FLOAT, + ) + + +def prompt_uuid(variable, default): + return click.prompt( + variable.prompt, + default=default, + hide_input=variable.hide_input, + type=click.UUID, + ) + + +def prompt_json(variable, default): + # The JSON object from cookiecutter.json might be very large + # We only show 'default' + DEFAULT_JSON = 'default' + + def process_json(user_value): + try: + return json.loads( + user_value, + object_pairs_hook=collections.OrderedDict, + ) + except ValueError: + # json.decoder.JSONDecodeError raised in Python 3.5, 3.6 + # but it inherits from ValueError which is raised in Python 3.4 + # --------------------------------------------------------------- + # Leave it up to click to ask the user again. + # Local function procsse_json() is called by click within a + # try block that catches click.UsageError exception's and asks + # the user to try again. + raise click.UsageError('Unable to decode to JSON.') + + dict_value = click.prompt( + variable.prompt, + default=DEFAULT_JSON, + hide_input=variable.hide_input, + type=click.STRING, + value_proc=process_json, + ) + + if dict_value == DEFAULT_JSON: + # Return the given default w/o any processing + return default + return dict_value + + +def prompt_yes_no(variable, default): + if default is True: + default_display = 'y' + else: + default_display = 'n' + + # click.prompt() behavior: + # When supplied with a string default, the string default is returned, + # rather than the string converted to a click.BOOL. + # If default is passed as a boolean then the default is displayed as + # [True] or [False], rather than [y] or [n]. + # This prompt translates y, yes, Yes, YES, n, no, No, NO to their correct + # boolean values, its just that it does not translate a string default + # value of y, yes, Yes, YES, n, no, No, NO to a boolean... + value = click.prompt( + variable.prompt, + default=default_display, + hide_input=variable.hide_input, + type=click.BOOL, + ) + + # ...so if we get the displayed default value back (its a string), + # change it to its associated boolean value + if value == default_display: + value = default + + return value + + +def prompt_choice(variable, default): + """Returns prompt, default and callback for a choice variable""" + choice_map = collections.OrderedDict( + (u'{}'.format(i), value) for i, value in enumerate(variable.choices, 1) + ) + choices = choice_map.keys() + + prompt = u'\n'.join( + ( + variable.prompt, + u'\n'.join([u'{} - {}'.format(*c) for c in choice_map.items()]), + u'Choose from {}'.format(u', '.join(choices)), + ) + ) + default = str(variable.choices.index(default) + 1) + + user_choice = click.prompt( + prompt, + default=default, + hide_input=variable.hide_input, + type=click.Choice(choices), + ) + return choice_map[user_choice] + + +PROMPTS = { + 'string': prompt_string, + 'boolean': prompt_boolean, + 'int': prompt_int, + 'float': prompt_float, + 'uuid': prompt_uuid, + 'json': prompt_json, + 'yes_no': prompt_yes_no, +} + + +def deserialize_string(value): + return str(value) + + +def deserialize_boolean(value): + return bool(value) + + +def deserialize_yes_no(value): + return bool(value) + + +def deserialize_int(value): + return int(value) + + +def deserialize_float(value): + return float(value) + + +def deserialize_uuid(value): + return click.UUID(value) + + +def deserialize_json(value): + return value + + +DESERIALIZERS = { + 'string': deserialize_string, + 'boolean': deserialize_boolean, + 'int': deserialize_int, + 'float': deserialize_float, + 'uuid': deserialize_uuid, + 'json': deserialize_json, + 'yes_no': deserialize_yes_no, +} + + +class Variable(object): + """ + Embody attributes of variables while processing the variables field of + a cookiecutter version 2 context. + """ + + def __init__(self, name, default, **info): + """ + :param name: A string containing the variable's name in the jinja2 + context. + :param default: The variable's default value. Can any type defined + below. + :param kwargs info: Keyword/Argument pairs recognized are shown below. + + Recognized Keyword/Arguments, but optional: + + - `description` -- A string description of the variable. + - `prompt` -- A string to show user when prompted for input. + - `prompt_user` -- A boolean, if True prompt user; else no prompt. + - `hide_input` -- A boolean, if True hide user's input. + - `type` -- Specifies the variable's data type see below, + defaults to string. + - `skip_if` -- A string of a jinja2 renderable boolean expression, + the variable will be skipped if it renders True. + - `do_if` -- A string of a jinja2 renderable boolean expression, + the variable will be processed if it renders True. + - `choices` -- A list of choices, may be of mixed types. + - `if_yes_skip_to` -- A string containing a variable name to skip + to if the yes_no value is True (yes). Only has meaning for + variables of type 'yes_no'. + - `if_no_skip_to` -- A string containing a variable name to skip + to if the yes_no value is False (no). Only has meaning for + variables of type 'yes_no'. + - `validation` -- A string defining a regex to use to validation + user input. Defaults to None. + - `validation_msg` -- A string defining an additional message to + display if the validation check fails. + - `validation_flags` -- A list of validation flag names that can be + specified to control the behaviour of the validation + check done using the above defined `validation` string. + Specifying a flag is equivalent to setting it to True, + not specifying a flag is equivalent to setting it to False. + The default value of this variable has no effect on the + validation check. + + The flags supported are: + + * ascii - enabling re.ASCII + * debug - enabling re.DEBUG + * ignorecase - enabling re.IGNORECASE + * locale - enabling re.LOCALE + * mulitline - enabling re.MULTILINE + * dotall - enabling re.DOTALL + * verbose - enabling re.VERBOSE + + See: https://docs.python.org/3/library/re.html#re.compile + + Supported Types + * string + * boolean + * int + * float + * uuid + * json + * yes_no + + """ + + # mandatory fields + self.name = name + self.default = default + + # optional fields + self.info = info + + # -- DESCRIPTION ----------------------------------------------------- + self.description = self.check_type('description', None, str) + + # -- PROMPT ---------------------------------------------------------- + self.prompt = self.check_type( + 'prompt', DEFAULT_PROMPT.format(variable=self), str + ) + + # -- HIDE_INPUT ------------------------------------------------------ + self.hide_input = self.check_type('hide_input', False, bool) + + # -- TYPE ------------------------------------------------------------ + self.var_type = info.get('type', 'string') + if self.var_type not in VALID_TYPES: + msg = 'Variable: {var_name} has an invalid type {var_type}. Valid types are: {types}' + raise ValueError( + msg.format( + var_type=self.var_type, var_name=self.name, types=VALID_TYPES + ) + ) + + # -- SKIP_IF --------------------------------------------------------- + self.skip_if = self.check_type('skip_if', '', str) + + # -- DO_IF --------------------------------------------------------- + self.do_if = self.check_type('do_if', '', str) + + # -- IF_YES_SKIP_TO --------------------------------------------------------- + self.if_yes_skip_to = self.check_type('if_yes_skip_to', None, str) + if self.if_yes_skip_to: + if self.var_type not in ['yes_no']: + msg = "Variable: '{var_name}' specifies 'if_yes_skip_to' field, but variable not of type 'yes_no'" + raise ValueError(msg.format(var_name=self.name)) + + # -- IF_NO_SKIP_TO --------------------------------------------------------- + self.if_no_skip_to = self.check_type('if_no_skip_to', None, str) + if self.if_no_skip_to: + if self.var_type not in ['yes_no']: + msg = "Variable: '{var_name}' specifies 'if_no_skip_to' field, but variable not of type 'yes_no'" + raise ValueError(msg.format(var_name=self.name)) + + # -- PROMPT_USER ----------------------------------------------------- + self.prompt_user = self.check_type('prompt_user', True, bool) + # do not prompt for private variable names (beginning with _) + if self.name.startswith('_'): + self.prompt_user = False + + # -- CHOICES --------------------------------------------------------- + # choices are somewhat special as they can be of every type + self.choices = self.check_type('choices', [], list) + if self.choices and default not in self.choices: + msg = "Variable: {var_name} has an invalid default value {default} for choices: {choices}." + raise ValueError( + msg.format( + var_name=self.name, default=self.default, choices=self.choices + ) + ) + + # -- VALIDATION STARTS ----------------------------------------------- + self.validation = self.check_type('validation', None, str) + + self.validation_msg = self.check_type('validation_msg', None, str) + + self.validation_flag_names = self.check_type('validation_flags', [], list) + + self.validation_flags = 0 + + for vflag in self.validation_flag_names: + if vflag in REGEX_COMPILE_FLAGS.keys(): + self.validation_flags |= REGEX_COMPILE_FLAGS[vflag] + else: + msg = ( + "Variable: {var_name} - Ignoring unkown RegEx validation Control Flag named '{flag}'\n" + "Legal flag names are: {names}" + ) + logger.warn( + msg.format( + var_name=self.name, flag=vflag, names=REGEX_COMPILE_FLAGS.keys() + ) + ) + self.validation_flag_names.remove(vflag) + + self.validate = None + if self.validation: + try: + self.validate = re.compile(self.validation, self.validation_flags) + except re.error as e: + msg = "Variable: {var_name} - Validation Setup Error: Invalid RegEx '{value}' - does not compile - {err}" + raise ValueError( + msg.format(var_name=self.name, value=self.validation, err=e) + ) + # -- VALIDATION ENDS ------------------------------------------------- + + def __repr__(self): + return "<{class_name} {variable_name}>".format( + class_name=self.__class__.__name__, + variable_name=self.name, + ) + + def __str__(self): + s = [ + "{key}='{value}'".format(key=key, value=self.__dict__[key]) + for key in self.__dict__ + if key != 'info' + ] + return self.__repr__() + ':\n' + ',\n'.join(s) + + def check_type(self, option_name, option_default_value, option_type): + """ + Retrieve the option_value named option_name from info and check its type. + Raise ValueError if the type is incorrect; otherwise return option's value. + """ + option_value = self.info.get(option_name, option_default_value) + + if option_value is not None: + if not isinstance(option_value, option_type): + msg = "Variable: '{var_name}' Option: '{opt_name}' requires a value of type {type_name}, but has a value of: {value}" + raise ValueError( + msg.format( + var_name=self.name, + opt_name=option_name, + type_name=option_type.__name__, + value=option_value, + ) + ) + + return option_value + + +class CookiecutterTemplate(object): + """ + Embodies all attributes of a version 2 Cookiecutter template. + """ + + def __init__(self, name, cookiecutter_version, variables, **info): + """ + Mandatorty Parameters + + :param name: A string, the cookiecutter template name + :param cookiecutter_version: A string containing the version of the + cookiecutter application that is compatible with this template. + :param variables: A list of OrderedDict items that describe each + variable in the template. These variables are essentially what + is found in the version 1 cookiecutter.json file. + + Optional Parameters (via \**info) + + :param authors: An array of string - maintainers of the template. + :param description: A string, human readable description of template. + :param keywords: An array of string - similar to PyPI keywords. + :param license: A string identifying the license of the template code. + :param url: A string containing the URL for the template project. + :param version: A string containing a version identifier, ideally + following the semantic versioning spec. + + """ + + # mandatory fields + self.name = name + self.cookiecutter_version = cookiecutter_version + self.variables = [Variable(**v) for v in variables] + + # optional fields + self.authors = info.get('authors', []) + self.description = info.get('description', None) + self.keywords = info.get('keywords', []) + self.license = info.get('license', None) + self.url = info.get('url', None) + self.version = info.get('version', None) + + def __repr__(self): + return "<{class_name} {template_name}>".format( + class_name=self.__class__.__name__, + template_name=self.name, + ) + + def __iter__(self): + for v in self.variables: + yield v + + +def load_context(json_object, no_input=False, verbose=True): + """ + Load a version 2 context & process the json_object for declared variables + in the Cookiecutter template. + + :param json_object: A JSON file that has be loaded into a Python OrderedDict. + :param no_input: Prompt the user at command line for manual configuration if False, + if True, no input prompts are made, all defaults are accepted. + :param verbose: Emit maximum varible information. + """ + env = Environment(extensions=['jinja2_time.TimeExtension'], autoescape=True) + context = collections.OrderedDict({}) + + skip_to_variable_name = None + + for variable in CookiecutterTemplate(**json_object): + if skip_to_variable_name: + if variable.name == skip_to_variable_name: + skip_to_variable_name = None + else: + # Is executed, but not marked so in coverage report, due to + # CPython's peephole optimizer's optimizations. + # See https://bitbucket.org/ned/coveragepy/issues/198/continue-marked-as-not-covered + # Issue #198 in coverage.py marked WONTFIX + continue # pragma: no cover + + if variable.skip_if: + skip_template = env.from_string(variable.skip_if) + skip_value = skip_template.render(cookiecutter=context) + if skip_value == 'True': + continue + + if variable.do_if: + do_template = env.from_string(variable.do_if) + if do_template.render(cookiecutter=context) == 'False': + continue + + default = variable.default + + if isinstance(default, str): + template = env.from_string(default) + default = template.render(cookiecutter=context) + + deserialize = DESERIALIZERS[variable.var_type] + + if no_input or (not variable.prompt_user): + context[variable.name] = deserialize(default) + else: + if variable.choices: + prompt = prompt_choice + else: + prompt = PROMPTS[variable.var_type] + + if verbose and variable.description: + click.echo(variable.description) + + while True: + value = prompt(variable, default) + if variable.validate: + if variable.validate.match(value): + break + else: + msg = "Input validation failure against regex: '{val_string}', try again!".format( + val_string=variable.validation + ) + click.echo(msg) + if variable.validation_msg: + click.echo(variable.validation_msg) + else: + # Assign a random variable here to disable the peephole + # optimizer so that coverage can see this line. + # See bpo-2506 for more information. + no_peephole_opt = None + # no validation defined + break + + if verbose: + width, _ = shutil.get_terminal_size() + click.echo('-' * width) + + context[variable.name] = deserialize(value) + + if variable.if_yes_skip_to and context[variable.name] is True: + skip_to_variable_name = variable.if_yes_skip_to + + if variable.if_no_skip_to and context[variable.name] is False: + skip_to_variable_name = variable.if_no_skip_to + + if skip_to_variable_name: + logger.warn( + "Processed all variables, but skip_to_variable_name '{}' was never found.".format( + skip_to_variable_name + ) + ) + + return context diff --git a/cookiecutter/generate.py b/cookiecutter/generate.py index 251d5f439..f6eb3b2e8 100644 --- a/cookiecutter/generate.py +++ b/cookiecutter/generate.py @@ -23,6 +23,8 @@ from cookiecutter.hooks import run_hook from cookiecutter.utils import make_sure_path_exists, rmtree, work_in +from cookiecutter.context import context_is_version_2 + logger = logging.getLogger(__name__) @@ -77,6 +79,224 @@ def apply_overwrites_to_context(context, overwrite_context): context[variable] = overwrite +def apply_default_overwrites_to_context_v2(context, overwrite_default_context): + """V2 context overwrites. + + Modify the given version 2 context in place based on the + overwrite_default_context. + """ + for variable, overwrite in overwrite_default_context.items(): + var_dict = next( + (d for d in context['variables'] if d['name'] == variable), None + ) # noqa + if var_dict: + if 'choices' in var_dict.keys(): + context_value = var_dict['choices'] + else: + context_value = var_dict['default'] + + if isinstance(context_value, list): + # 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 1st item in list) + # see ``cookiecutter.prompt.prompt_choice_for_config`` + context_value.remove(overwrite) + context_value.insert(0, overwrite) + var_dict['default'] = overwrite + else: + # Simply overwrite the value for this variable + var_dict['default'] = overwrite + + +def resolve_changed_variable_names(context, variables_to_resolve): + """Resolve changed variable names. + + The variable names contained in the variables_to_resolve dictionary's + key names have been over-written with keys' value. Check the entire + context and update any other variable context fields that may still + reference the original variable name. + """ + for var_name_to_resolve in variables_to_resolve: + + new_var_name = variables_to_resolve[var_name_to_resolve] + + for variable in context['variables']: + for field_name in variable.keys(): + if isinstance(variable[field_name], str): + if var_name_to_resolve in variable[field_name]: + variable[field_name] = variable[field_name].replace( + var_name_to_resolve, new_var_name + ) # noqa + + elif isinstance(variable[field_name], list): + # a choices field could have a str item to update + for i, item in enumerate(variable[field_name]): + if isinstance(item, str): + if var_name_to_resolve in item: + variable[field_name][i] = item.replace( + var_name_to_resolve, new_var_name + ) # noqa + + +def apply_overwrites_to_context_v2(context, extra_context): + """Modify the given version 2 context in place based on extra_context. + + :parameter context: cookiecutter context. + :parameter extra_context: optional dictionary of key/value pairs to + + The extra_context parameter may be a dictionary or a list of dictionaries. + + If extra_context is a dictionary, the key is assumed to identify the + variable's 'name' field and the value will be applied to the name field's + default value -- this behavior is exactly like version 1 context overwrite + behavior. + + When extra_context is a list of dictionaries, each dictionary MUST specify + at the very least a 'name' key/value pair, or a ValueError is raised. The + 'name' key's value will be used to find the variable dictionary to + overwrite by matching each dictionary's 'name' field. + + If extra_context is a list of dictionaries, apply the overwrite from each + dictionary to it's matching variable's dictionary. This allows all fields + of a variable to be updated. A match considers the variable's 'name' field + only; any name fields in the extra_context list of dictionaries that do + not match a variable 'name' field, are ignored. Any key/value pairs + specified in an extra_content dictionary that are not already defined by + the matching variable dictionary will raise a ValueError. + + Changing the 'name' Field + ------------------------- + Changing the 'name' field requires a special syntax. Because the algorithm + chosen to find a variable’s dictionary entry in the variables list of + OrderDicts uses the variable’s ‘name’ field; it could not be used to + simultaneously hold a new ‘name’ field value. Therefor the following + extra context dictionary entry sytax was introduced to allow the ‘name’ + field of a variable to be changed: + + { 'name': 'CURRENT_VARIABLE_NAME::NEW_VARIABLE_NAME',} + + So, for example, to change a variable’s ‘name’ field from + ‘director_credit’ to ‘producer_credit’, would require: + + { 'name': 'director_credit::producer_credit', } + + + Removing a Field from a Variable + -------------------------------- + It is possible that a previous extra context overwrite requires that a + subsequent variable entry be removed. + + In order to accomplish this a remove field token is used in the extra + context as follows: + + { 'name': 'director_cut', + 'skip_if': '<>', } + + In the example above, the extra context overwrite results in the variable + named ‘director_cut’ having it’s ‘skip_if’ field removed. + + Overwrite Considerations Regarding ‘default’ & ‘choices’ Fields + --------------------------------------------------------------- + When a variable is defined that has both the ‘default’ and the ‘choices’ + fields, these two fields influence each other. If one of these fields is + updated, but not the other field, then the other field will be + automatically updated by the overwrite logic. + + If both fields are updated, then the ‘default’ value will be moved to the + first location of the ‘choices’ field if it exists elsewhere in the list; + if the default value is not in the list, it will be added to the first + location in the choices list. + + """ + variable_names_to_resolve = {} + if isinstance(extra_context, dict): + apply_default_overwrites_to_context_v2(context, extra_context) + elif isinstance(extra_context, list): + for xtra_ctx_item in extra_context: + if isinstance(xtra_ctx_item, dict): + if 'name' in xtra_ctx_item.keys(): + # xtra_ctx_item['name'] may have a replace value of the + # form: + # 'name_value::replace_name_value' + xtra_ctx_name = xtra_ctx_item['name'].split('::')[0] + try: + replace_name = xtra_ctx_item['name'].split('::')[1] + except IndexError: + replace_name = None + + var_dict = next( + (d for d in context['variables'] if d['name'] == xtra_ctx_name), + None, + ) # noqa + if var_dict: + # Since creation of new key/value pairs is NOT + # desired, we only use a key that is common to both + # the variables context and the extra context. + common_keys = [ + key + for key in xtra_ctx_item.keys() + if key in var_dict.keys() + ] # noqa + for key in common_keys: + if xtra_ctx_item[key] == '<>': + if key in ['default']: + raise ValueError( + "Cannot remove mandatory 'default' field" + ) # noqa + var_dict.pop(key, None) + else: + # normal field update + var_dict[key] = xtra_ctx_item[key] + + # After all fields have been updated, there is some + # house-keeping to do. The default/choices + # house-keeping could effecively be no-ops if the + # user did the correct thing. + if ('default' in common_keys) & ( + 'choices' in var_dict.keys() + ): # noqa + # default updated, regardless if choices has been + # updated, re-order choices based on default + if var_dict['default'] in var_dict['choices']: + var_dict['choices'].remove(var_dict['default']) # noqa + + var_dict['choices'].insert(0, var_dict['default']) + + if ('default' not in common_keys) & ( + 'choices' in common_keys + ): # noqa + # choices updated, so update default based on + # first location in choices + var_dict['default'] = var_dict['choices'][0] + + if replace_name: + variable_names_to_resolve[ + xtra_ctx_name + ] = replace_name # noqa + var_dict['name'] = replace_name + else: + msg = "No variable found in context whose name matches extra context name '{name}'" # noqa + raise ValueError(msg.format(name=xtra_ctx_name)) + else: + msg = "Extra context dictionary item {item} is missing a 'name' key." # noqa + raise ValueError(msg.format(item=xtra_ctx_item)) + else: + msg = "Extra context list item '{item}' is of type {t}, should be a dictionary." # noqa + raise ValueError( + msg.format(item=str(xtra_ctx_item), t=type(xtra_ctx_item).__name__) + ) # noqa + + if variable_names_to_resolve: + # At least one variable name has been over-written, if any + # variables use the original name, they must get updated as well + resolve_changed_variable_names(context, variable_names_to_resolve) + + else: + msg = "Extra context must be a dictionary or a list of dictionaries!" + raise ValueError(msg) + + def generate_context( context_file='cookiecutter.json', default_context=None, extra_context=None ): @@ -112,13 +332,26 @@ def generate_context( # 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) + if context_is_version_2(context[file_stem]): + logger.debug("Context is version 2") + + if default_context: + try: + apply_overwrites_to_context_v2(obj, default_context) + except ValueError as error: + warnings.warn(f"Invalid default received: {error}") + if extra_context: + apply_overwrites_to_context_v2(obj, extra_context) + else: + logger.debug("Context is version 1") + + 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 @@ -361,8 +594,8 @@ def generate_files( shutil.rmtree(outdir) shutil.copytree(indir, outdir) - # We mutate ``dirs``, because we only want to go through these dirs - # recursively + # 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) diff --git a/cookiecutter/main.py b/cookiecutter/main.py index ba1df1b6c..adca601aa 100644 --- a/cookiecutter/main.py +++ b/cookiecutter/main.py @@ -17,6 +17,8 @@ from cookiecutter.repository import determine_repo_dir from cookiecutter.utils import rmtree +from cookiecutter.context import context_is_version_2, load_context + logger = logging.getLogger(__name__) @@ -103,8 +105,13 @@ def cookiecutter( # prompt the user to manually configure at the command line. # except when 'no-input' flag is set - with import_patch: - context['cookiecutter'] = prompt_for_config(context, no_input) + if context_is_version_2(context['cookiecutter']): + context['cookiecutter'] = load_context( + context[u'cookiecutter'], no_input=no_input, verbose=True + ) + else: + with import_patch: + context['cookiecutter'] = prompt_for_config(context, no_input) # include template dir or url in the context dict context['cookiecutter']['_template'] = template diff --git a/docs/cookiecutter.rst b/docs/cookiecutter.rst index 89c857dc4..f1086a079 100644 --- a/docs/cookiecutter.rst +++ b/docs/cookiecutter.rst @@ -21,6 +21,14 @@ cookiecutter.config module :undoc-members: :show-inheritance: +cookiecutter.context module +--------------------------- + +.. automodule:: cookiecutter.context + :members: + :undoc-members: + :show-inheritance: + cookiecutter.environment module ------------------------------- diff --git a/setup.cfg b/setup.cfg index 3f29fe9a0..f3b8719a7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -10,7 +10,7 @@ max-line-length = 88 [tool:pytest] testpaths = tests -addopts = -vvv --cov-report term-missing --cov=cookiecutter +addopts = -vvv --cov-report=html --cov-report=xml --cov-branch --cov-fail-under=100 --cov-report term-missing --cov=cookiecutter [doc8] # TODO: Remove current max-line-length ignore in follow-up and adopt black limit. diff --git a/tests/test-context/cookiecutter.json b/tests/test-context/cookiecutter.json new file mode 100644 index 000000000..eb746bb4d --- /dev/null +++ b/tests/test-context/cookiecutter.json @@ -0,0 +1,166 @@ +{ + "name": "cookiecutter-pytest-plugin", + "version": "0.1.0", + "description": "a cookiecutter to create pytest plugins with ease.", + "authors": [ + "Raphael Pierzina ", + "Audrey Roy Greenfeld " + ], + "cookiecutter_version": "2.0.0", + "license": "MIT", + "keywords": [ + "pytest", + "python", + "plugin" + ], + "url": "https://github.com/pytest-dev/cookiecutter-pytest-plugin", + "environment_settings": { + "keep_trailing_newline": true, + "comment_start_string": "{##", + "comment_end_string": "##}" + }, + "variables": [ + { + "name": "full_name", + "default": "Raphael Pierzina", + "prompt": "What's your full name?", + "description": "Please enter your full name. It will be displayed on the README file and used for the PyPI package definition.", + "type": "string" + }, + { + "name": "email", + "default": "raphael@hackebrot.de", + "prompt": "What's your email?", + "description": "Please enter an email address for the meta information in setup.py.", + "type": "string" + }, + { + "name": "secret_token", + "default": null, + "prompt": "Please enter your secret token", + "type": "string", + "hide_input": true + }, + { + "name": "plugin_name", + "default": "emoji", + "prompt": "What should be the name for your plugin?", + "description": "Please enter a name for your plugin. We will prepend the name with 'pytest-'", + "type": "string" + }, + { + "name": "module_name", + "default": "{{cookiecutter.plugin_name|lower|replace('-','_')}}", + "prompt": "Please enter a name for your base python module", + "type": "string", + "validation": "^[a-z_]+$" + }, + { + "name": "license", + "default": "MIT", + "prompt": "Please choose a license!", + "description": "Cookiecutter will add an according LICENSE file for you and set the according classifier in setup.py.", + "type": "string", + "choices": [ + "MIT", + "BSD-3", + "GNU GPL v3.0", + "Apache Software License 2.0", + "Mozilla Public License 2.0" + ] + }, + { + "name": "docs", + "default": false, + "prompt": "Do you want to generate a base for docs?", + "description": "Would you like to generate documentation for your plugin? You will be able to choose from a number of generators.", + "type": "yes_no" + }, + { + "name": "docs_tool", + "default": "mkdocs", + "prompt": "Which tool do you want to choose for generating docs?", + "description": "There are a number of options for documentation generators. Please choose one. We will create a separate folder for you", + "type": "string", + "choices": [ + "mkdocs", + "sphinx" + ], + "skip_if": "{{cookiecutter.docs == False}}" + }, + { + "name": "docs_tool_skip", + "default": "mkdocs", + "prompt": "Which tool do you want to choose for generating docs?", + "description": "There are a number of options for documentation generators. Please choose one. We will create a separate folder for you", + "type": "string", + "choices": [ + "mkdocs", + "sphinx" + ], + "skip_if": "{{cookiecutter.docs == True}}" + }, + { + "name": "year", + "default": "{% now 'utc', '%Y' %}", + "prompt_user": false, + "type": "string" + }, + { + "name": "incept_year", + "default": 2017, + "prompt_user": false, + "type": "int" + }, + { + "name": "released", + "default": false, + "prompt_user": false, + "type": "boolean" + }, + { + "name": "temperature", + "default": 77.3, + "prompt_user": false, + "type": "float" + }, + { + "name": "Release-GUID", + "default": "04f5eaa9ee7345469dccffc538b27194", + "prompt_user": false, + "type": "uuid" + }, + { + "name": "extensions", + "default": ["jinja2_time.TimeExtension"], + "prompt_user": false, + "type": "string" + }, + { + "name": "copy_with_out_render", + "default": [ + "*.html", + "*not_rendered_dir", + "rendered_dir/not_rendered_file.ini" + ], + "prompt_user": false, + "type": "string" + }, + { + "name": "fixtures", + "default": { + "foo": { + "scope": "session", + "autouse": true + }, + "bar": { + "scope": "function", + "autouse": false + } + }, + "description": "Please enter a valid JSON string to set up fixtures for your plugin.", + "prompt_user": true, + "type": "json" + } + ] +} diff --git a/tests/test-context/cookiecutter_choices.json b/tests/test-context/cookiecutter_choices.json new file mode 100644 index 000000000..68b84b092 --- /dev/null +++ b/tests/test-context/cookiecutter_choices.json @@ -0,0 +1,36 @@ +{ + "name": "cookiecutter-test-choices-with-inputs", + "version": "0.1.0", + "cookiecutter_version": "2.0.0", + "variables": [ + { + "name": "full_name", + "default": "Raphael Pierzina", + "prompt": "What's your full name?", + "description": "Please enter your full name. It will be displayed on the README file and used for the PyPI package definition.", + "type": "string" + }, + { + "name": "email", + "default": "raphael@hackebrot.de", + "prompt": "What's your email?", + "description": "Please enter an email address for the meta information in setup.py.", + "type": "string" + }, + { + "name": "license", + "default": "ISC", + "prompt": "Please choose a license!", + "description": "Cookiecutter will add an according LICENSE file for you and set the according classifier in setup.py.", + "type": "string", + "choices": [ + "ISC", + "MIT", + "BSD3", + "GNU-GPL3", + "Apache2", + "Mozilla2" + ] + } + ] +} diff --git a/tests/test-context/cookiecutter_skips_1.json b/tests/test-context/cookiecutter_skips_1.json new file mode 100644 index 000000000..523ee217f --- /dev/null +++ b/tests/test-context/cookiecutter_skips_1.json @@ -0,0 +1,101 @@ +{ + "name": "cookiecutter-testing-skips", + "version": "0.1.0", + "cookiecutter_version": "2.0.0", + "variables": [ + { + "name": "project_configuration_enabled", + "default": false, + "prompt": "Will this project require a configuration file?", + "type": "yes_no", + "if_no_skip_to": "project_uses_existing_logging_facilities" + }, + { + "name": "project_config_format", + "default": "toml", + "prompt": "Select a configuration file format.", + "type": "string", + "choices": [ + "toml", + "yaml", + "json", + "ini" + ] + }, + { + "name": "project_uses_existing_logging_facilities", + "default": true, + "prompt": "Will this project use existing external logging facilities?", + "type": "yes_no", + "if_yes_skip_to": "github_username" + }, + { + "name": "project_logging_enabled", + "default": true, + "prompt": "Will this project provide its own logging facilities?", + "type": "yes_no", + "if_no_skip_to": "github_username" + }, + { + "name": "project_console_logging_enabled", + "default": true, + "prompt": "Will the project's logging facilities include logging to the console?", + "type": "yes_no", + "if_no_skip_to": "project_file_logging_enabled", + "do_if": "{{cookiecutter.project_logging_enabled == True}}" + }, + { + "name": "project_console_logging_level", + "default": "WARN", + "prompt": "Select the minimum logging level to log to the console.", + "type": "string", + "choices": [ + "WARN", + "INFO", + "DEBUG", + "ERROR" + ], + "do_if": "{{cookiecutter.project_logging_enabled == True}}" + }, + { + "name": "project_console_logging_level_skip", + "default": "WARN", + "prompt": "Select the minimum logging level to log to the console.", + "type": "string", + "choices": [ + "WARN", + "INFO", + "DEBUG", + "ERROR" + ], + "skip_if": "{{cookiecutter.project_logging_enabled == False}}" + }, + { + "name": "project_file_logging_enabled", + "default": true, + "prompt": "Will the project's logging facilities include logging to a file?", + "type": "yes_no", + "if_no_skip_to": "github_username", + "do_if": "{{cookiecutter.project_logging_enabled == True}}" + }, + { + "name": "project_file_logging_level", + "default": "DEBUG", + "prompt": "Select the minimum logging level to log to a file", + "type": "string", + "choices": [ + "DEBUG", + "INFO", + "WARN", + "ERROR" + ], + "do_if": "{{cookiecutter.project_logging_enabled == True}}" + }, + { + "name": "github_username", + "default": "eruber", + "prompt": "Enter your GitHub User Name", + "type": "string" + } + ] +} diff --git a/tests/test-context/cookiecutter_skips_2.json b/tests/test-context/cookiecutter_skips_2.json new file mode 100644 index 000000000..64fb9d208 --- /dev/null +++ b/tests/test-context/cookiecutter_skips_2.json @@ -0,0 +1,88 @@ +{ + "name": "cookiecutter-testing-skips", + "cookiecutter_version": "2.0.0", + "version": "0.1.0", + "variables": [ + { + "name": "project_configuration_enabled", + "default": true, + "prompt": "Will this project require a configuration file?", + "type": "yes_no", + "if_no_skip_to": "project_uses_existing_logging_facilities" + }, + { + "name": "project_config_format", + "default": "toml", + "prompt": "Select a configuration file format.", + "type": "string", + "choices": [ + "toml", + "yaml", + "json", + "ini" + ] + }, + { + "name": "project_uses_existing_logging_facilities", + "default": false, + "prompt": "Will this project use existing external logging facilities?", + "type": "yes_no", + "if_yes_skip_to": "github_username" + }, + { + "name": "project_logging_enabled", + "default": true, + "prompt": "Will this project provide its own logging facilities?", + "type": "yes_no", + "if_no_skip_to": "github_username" + }, + { + "name": "project_console_logging_enabled", + "default": true, + "prompt": "Will the project's logging facilities include logging to the console?", + "type": "yes_no", + "if_no_skip_to": "project_file_logging_enabled", + "do_if": "{{cookiecutter.project_logging_enabled == True}}" + }, + { + "name": "project_console_logging_level", + "default": "WARN", + "prompt": "Select the minimum logging level to log to the console.", + "type": "string", + "choices": [ + "WARN", + "INFO", + "DEBUG", + "ERROR" + ], + "do_if": "{{cookiecutter.project_logging_enabled == True}}" + }, + { + "name": "project_file_logging_enabled", + "default": true, + "prompt": "Will the project's logging facilities include logging to a file?", + "type": "yes_no", + "if_no_skip_to": "github_username", + "do_if": "{{cookiecutter.project_logging_enabled == True}}" + }, + { + "name": "project_file_logging_level", + "default": "DEBUG", + "prompt": "Select the minimum logging level to log to a file", + "type": "string", + "choices": [ + "DEBUG", + "INFO", + "WARN", + "ERROR" + ], + "do_if": "{{cookiecutter.project_logging_enabled == False}}" + }, + { + "name": "github_username", + "default": "eruber", + "prompt": "Enter your GitHub User Name", + "type": "string" + } + ] +} diff --git a/tests/test-context/cookiecutter_skips_3.json b/tests/test-context/cookiecutter_skips_3.json new file mode 100644 index 000000000..37ae8db58 --- /dev/null +++ b/tests/test-context/cookiecutter_skips_3.json @@ -0,0 +1,69 @@ +{ + "name": "cookiecutter-testing-skips", + "cookiecutter_version": "2.0.0", + "version": "0.1.0", + "variables": [ + { + "name": "project_uses_existing_logging_facilities", + "default": false, + "prompt": "Will this project use existing external logging facilities?", + "type": "yes_no", + "if_no_skip_to": "this_variable_name_is_not_in_the_list" + }, + { + "name": "project_logging_enabled", + "default": true, + "prompt": "Will this project provide its own logging facilities?", + "type": "yes_no", + "if_no_skip_to": "github_username" + }, + { + "name": "project_console_logging_enabled", + "default": true, + "prompt": "Will the project's logging facilities include logging to the console?", + "type": "yes_no", + "if_no_skip_to": "project_file_logging_enabled", + "do_if": "{{cookiecutter.project_logging_enabled == True}}" + }, + { + "name": "project_console_logging_level", + "default": "WARN", + "prompt": "Select the minimum logging level to log to the console.", + "type": "string", + "choices": [ + "WARN", + "INFO", + "DEBUG", + "ERROR" + ], + "do_if": "{{cookiecutter.project_logging_enabled == True}}" + }, + { + "name": "project_file_logging_enabled", + "default": true, + "prompt": "Will the project's logging facilities include logging to a file?", + "type": "yes_no", + "if_no_skip_to": "github_username", + "do_if": "{{cookiecutter.project_logging_enabled == True}}" + }, + { + "name": "project_file_logging_level", + "default": "DEBUG", + "prompt": "Select the minimum logging level to log to a file", + "type": "string", + "choices": [ + "DEBUG", + "INFO", + "WARN", + "ERROR" + ], + "do_if": "{{cookiecutter.project_logging_enabled == False}}" + }, + { + "name": "github_username", + "default": "eruber", + "prompt": "Enter your GitHub User Name", + "type": "string" + } + ] +} diff --git a/tests/test-context/cookiecutter_val_failure.json b/tests/test-context/cookiecutter_val_failure.json new file mode 100644 index 000000000..f61eab83e --- /dev/null +++ b/tests/test-context/cookiecutter_val_failure.json @@ -0,0 +1,21 @@ +{ + "name": "cookiecutter-test-validation-failure", + "version": "0.1.0", + "cookiecutter_version": "2.0.0", + "variables": [ + { + "name": "project_name", + "default": "Default Project Name", + "prompt": "Enter Project Name", + "description": "Please enter a short, space delimited, name for this project.", + "type": "string" + }, + { + "name": "module_name", + "default": "{{cookiecutter.project_name|lower|replace(' ','_')}}", + "prompt": "Please enter a name for your base python module", + "type": "string", + "validation": "^[a-z_]+$" + } + ] +} diff --git a/tests/test-context/cookiecutter_val_failure_msg.json b/tests/test-context/cookiecutter_val_failure_msg.json new file mode 100644 index 000000000..a68c30b47 --- /dev/null +++ b/tests/test-context/cookiecutter_val_failure_msg.json @@ -0,0 +1,22 @@ +{ + "name": "cookiecutter-test-validation-failure", + "version": "0.1.0", + "cookiecutter_version": "2.0.0", + "variables": [ + { + "name": "project_name", + "default": "Default Project Name", + "prompt": "Enter Project Name", + "description": "Please enter a short, space delimited, name for this project.", + "type": "string" + }, + { + "name": "module_name", + "default": "{{cookiecutter.project_name|lower|replace(' ','_')}}", + "prompt": "Please enter a name for your base python module", + "type": "string", + "validation": "^[a-z_]+$", + "validation_msg": "Really, you couldn't get this correct the first time?" + } + ] +} diff --git a/tests/test-context/cookiecutter_val_success.json b/tests/test-context/cookiecutter_val_success.json new file mode 100644 index 000000000..12bae56ef --- /dev/null +++ b/tests/test-context/cookiecutter_val_success.json @@ -0,0 +1,21 @@ +{ + "name": "cookiecutter-test-validation-success", + "version": "0.1.0", + "cookiecutter_version": "2.0.0", + "variables": [ + { + "name": "project_name", + "default": "Default Project Name", + "prompt": "Enter Project Name", + "description": "Please enter a short, space delimited, name for this project.", + "type": "string" + }, + { + "name": "module_name", + "default": "{{cookiecutter.project_name|lower|replace(' ','_')}}", + "prompt": "Please enter a name for your base python module", + "type": "string", + "validation": "^[a-z_]+$" + } + ] +} diff --git a/tests/test-generate-context-v2/min-v2-cookiecutter/cookiecutter.json b/tests/test-generate-context-v2/min-v2-cookiecutter/cookiecutter.json new file mode 100644 index 000000000..7c257efe1 --- /dev/null +++ b/tests/test-generate-context-v2/min-v2-cookiecutter/cookiecutter.json @@ -0,0 +1,5 @@ +{ + "name": "cookiecutter-fake-minimun-template", + "cookiecutter_version": "2.0.0", + "variables" : [] +} diff --git a/tests/test-generate-context-v2/min-v2-cookiecutter/{{cookiecutter.repo_name}}/file b/tests/test-generate-context-v2/min-v2-cookiecutter/{{cookiecutter.repo_name}}/file new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test-generate-context-v2/representative-director.json b/tests/test-generate-context-v2/representative-director.json new file mode 100644 index 000000000..51b9490b0 --- /dev/null +++ b/tests/test-generate-context-v2/representative-director.json @@ -0,0 +1,36 @@ +{ + "name": "cc-representative", + "cookiecutter_version": "2.0.0", + "variables" : [ + { + "name": "director_credit", + "default": true, + "prompt": "Is there a director credit on this film?", + "description": "Directors take credit for most of their films, usually...", + "type": "boolean" + }, + { + "name": "director_exists", + "default": false, + "prompt": "Is there a Director?", + "prompt_user": true, + "description": "The director exists.", + "hide_input": false, + "choices": [true, false], + "type": "boolean" + }, + { + "name": "director_name", + "default": "Allan Smithe", + "prompt": "What's the Director's full name?", + "prompt_user": true, + "description": "The default director is not proud of their work, we hope you are.", + "hide_input": false, + "choices": ["Allan Smithe", "Ridley Scott", "Victor Fleming", "John Houston"], + "validation": "^[a-z][A-Z]+$", + "validation_flags": ["verbose", "ascii"], + "skip_if": "{{cookiecutter.director_credit == False}}", + "type": "string" + } + ] +} diff --git a/tests/test-generate-context-v2/representative-var.json b/tests/test-generate-context-v2/representative-var.json new file mode 100644 index 000000000..3be4d675a --- /dev/null +++ b/tests/test-generate-context-v2/representative-var.json @@ -0,0 +1,26 @@ +{ + "name": "cc-representative", + "cookiecutter_version": "2.0.0", + "variables" : [ + { + "name": "director_credit", + "default": true, + "prompt": "Is there a director credit on this film?", + "description": "Directors take credit for most of their films, usually...", + "type": "boolean" + }, + { + "name": "director_name", + "default": "Allan Smithe", + "prompt": "What's the Director's full name?", + "prompt_user": true, + "description": "The default director is not proud of their work, we hope you are.", + "hide_input": false, + "choices": ["Allan Smithe", "Ridley Scott", "Victor Fleming", "John Houston","{{cookiecutter.director_name}}"], + "validation": "^[a-z][A-Z]+$", + "validation_flags": ["verbose", "ascii"], + "skip_if": "{{cookiecutter.director_credit == False}}", + "type": "string" + } + ] +} diff --git a/tests/test-generate-context-v2/representative.json b/tests/test-generate-context-v2/representative.json new file mode 100644 index 000000000..3d47be2e2 --- /dev/null +++ b/tests/test-generate-context-v2/representative.json @@ -0,0 +1,26 @@ +{ + "name": "cc-representative", + "cookiecutter_version": "2.0.0", + "variables" : [ + { + "name": "director_credit", + "default": true, + "prompt": "Is there a director credit on this film?", + "description": "Directors take credit for most of their films, usually...", + "type": "boolean" + }, + { + "name": "director_name", + "default": "Allan Smithe", + "prompt": "What's the Director's full name?", + "prompt_user": true, + "description": "The default director is not proud of their work, we hope you are.", + "hide_input": false, + "choices": ["Allan Smithe", "Ridley Scott", "Victor Fleming", "John Houston"], + "validation": "^[a-z][A-Z]+$", + "validation_flags": ["verbose", "ascii"], + "skip_if": "{{cookiecutter.director_credit == False}}", + "type": "string" + } + ] +} diff --git a/tests/test-generate-context-v2/representative_2B.json b/tests/test-generate-context-v2/representative_2B.json new file mode 100644 index 000000000..2eb0fb2fa --- /dev/null +++ b/tests/test-generate-context-v2/representative_2B.json @@ -0,0 +1,26 @@ +{ + "name": "cc-representative", + "cookiecutter_version": "2.0.0", + "variables" : [ + { + "name": "director_credit", + "default": true, + "prompt": "Is there a director credit on this film?", + "description": "Directors take credit for most of their films, usually...", + "type": "boolean" + }, + { + "name": "director_name", + "default": "Allan Smithe", + "prompt": "What's the Director's full name?", + "prompt_user": true, + "description": "The default director is not proud of their work, we hope you are.", + "hide_input": false, + "choices": ["Allan Smithe", "Ridley Scott", "Victor Fleming", "John Houston", "{{cookiecutter.director_credit}}"], + "validation": "^[a-z][A-Z]+$", + "validation_flags": ["verbose", "ascii"], + "skip_if": "{{cookiecutter.director_credit == False}}", + "type": "string" + } + ] +} diff --git a/tests/test-generate-context-v2/test.json b/tests/test-generate-context-v2/test.json new file mode 100644 index 000000000..7050912f2 --- /dev/null +++ b/tests/test-generate-context-v2/test.json @@ -0,0 +1,20 @@ +{ + "name": "cookiecutter-pytest-plugin", + "cookiecutter_version": "2.0.0", + "variables" : [ + { + "name": "full_name", + "default": "J. Paul Getty", + "prompt": "What's your full name?", + "description": "Please enter your full name. It will be displayed on the README file and used for the PyPI package definition.", + "type": "string" + }, + { + "name": "email", + "default": "jpg@rich.de", + "prompt": "What's your email?", + "description": "Please enter an email address for the meta information in setup.py.", + "type": "string" + } + ] +} diff --git a/tests/test-generate-context-v2/test_choices-miss.json b/tests/test-generate-context-v2/test_choices-miss.json new file mode 100644 index 000000000..5ced20b25 --- /dev/null +++ b/tests/test-generate-context-v2/test_choices-miss.json @@ -0,0 +1,17 @@ +{ + "name": "test_choices-miss", + "cookiecutter_version": "2.0.0", + "variables" : [ + { + "name": "license", + "default": "Apache2", + "choices": [ + "MIT", + "BSD3", + "GNU-GPL3", + "Apache2", + "Mozilla2" + ] + } + ] +} diff --git a/tests/test-generate-context-v2/test_choices.json b/tests/test-generate-context-v2/test_choices.json new file mode 100644 index 000000000..0600bf7aa --- /dev/null +++ b/tests/test-generate-context-v2/test_choices.json @@ -0,0 +1,17 @@ +{ + "name": "cookiecutter-pytest-plugin", + "cookiecutter_version": "2.0.0", + "variables" : [ + { + "name": "license", + "default": "MIT", + "choices": [ + "MIT", + "BSD3", + "GNU-GPL3", + "Apache2", + "Mozilla2" + ] + } + ] +} diff --git a/tests/test_context.py b/tests/test_context.py new file mode 100644 index 000000000..4cc32feaf --- /dev/null +++ b/tests/test_context.py @@ -0,0 +1,823 @@ +# -*- coding: utf-8 -*- +# flake8: noqa +""" +test_context +------------ + +Tests for `cookiecutter.context` module that handles prompts for v2 context. +""" +from __future__ import unicode_literals + +import sys +import os.path +import time +import json +import logging + +import pytest + +from collections import OrderedDict + +from cookiecutter import context + +from cookiecutter.exceptions import ContextDecodingException + +import click + +from uuid import UUID + +logger = logging.getLogger(__name__) + + +def load_cookiecutter(cookiecutter_file): + + context = {} + try: + with open(cookiecutter_file) 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(cookiecutter_file) + json_exc_message = str(e) + our_exc_message = ( + 'JSON decoding error while loading "{0}". Decoding' + ' error details: "{1}"'.format(full_fpath, json_exc_message) + ) + raise ContextDecodingException(our_exc_message) + + # Add the Python object to the context dictionary + file_name = os.path.split(cookiecutter_file)[1] + file_stem = file_name.split('.')[0] + context[file_stem] = obj + + return context + + +def context_data_check(): + context_all_reqs = ( + { + 'cookiecutter_context': OrderedDict( + [ + ("name", "cookiecutter-pytest-plugin"), + ("cookiecutter_version", "2.0.0"), + ("variables", []), + ] + ) + }, + True, + ) + + context_missing_name = ( + { + 'cookiecutter_context': OrderedDict( + [("cookiecutter_version", "2.0.0"), ("variables", [])] + ) + }, + False, + ) + + context_missing_cookiecutter_version = ( + { + 'cookiecutter_context': OrderedDict( + [("name", "cookiecutter-pytest-plugin"), ("variables", [])] + ) + }, + False, + ) + + context_missing_variables = ( + { + 'cookiecutter_context': OrderedDict( + [ + ("name", "cookiecutter-pytest-plugin"), + ("cookiecutter_version", "2.0.0"), + ] + ) + }, + False, + ) + + yield context_all_reqs + yield context_missing_name + yield context_missing_cookiecutter_version + yield context_missing_variables + + +@pytest.mark.usefixtures('clean_system') +@pytest.mark.parametrize('input_params, expected_result', context_data_check()) +def test_context_check(input_params, expected_result): + """ + Test that a context with the required fields will be detected as a + v2 context. + """ + assert context.context_is_version_2(**input_params) == expected_result + + +@pytest.mark.usefixtures('clean_system') +def test_load_context_defaults(): + + cc = load_cookiecutter('tests/test-context/cookiecutter.json') + + cc_cfg = context.load_context(cc['cookiecutter'], no_input=True) + + assert cc_cfg['full_name'] == 'Raphael Pierzina' + assert cc_cfg['email'] == 'raphael@hackebrot.de' + assert cc_cfg['plugin_name'] == 'emoji' + assert cc_cfg['module_name'] == 'emoji' + assert cc_cfg['license'] == 'MIT' + assert cc_cfg['docs'] is False + assert 'docs_tool' not in cc_cfg.keys() # skip_if worked + assert cc_cfg['year'] == time.strftime('%Y') + assert cc_cfg['incept_year'] == 2017 + assert cc_cfg['released'] is False + assert cc_cfg['temperature'] == 77.3 + assert cc_cfg['Release-GUID'] == UUID('04f5eaa9ee7345469dccffc538b27194') + assert cc_cfg['extensions'] == "['jinja2_time.TimeExtension']" + assert ( + cc_cfg['copy_with_out_render'] + == "['*.html', '*not_rendered_dir', 'rendered_dir/not_rendered_file.ini']" + ) + assert cc_cfg['fixtures'] == OrderedDict( + [ + ('foo', OrderedDict([('scope', 'session'), ('autouse', True)])), + ('bar', OrderedDict([('scope', 'function'), ('autouse', False)])), + ] + ) + + +@pytest.mark.usefixtures('clean_system') +def test_load_context_defaults_skips_branch(): + """ + Test that if_no_skip_to and if_yes_skip_to actually do branch and + skip variables + """ + cc = load_cookiecutter('tests/test-context/cookiecutter_skips_1.json') + + cc_cfg = context.load_context(cc['cookiecutter_skips_1'], no_input=True) + + assert cc_cfg['project_configuration_enabled'] is False + assert 'project_config_format' not in cc_cfg.keys() # it was skipped + + assert cc_cfg['project_uses_existing_logging_facilities'] is True + assert 'project_logging_enabled' not in cc_cfg.keys() # it was skipped + assert 'project_console_logging_enabled' not in cc_cfg.keys() # it was skipped + assert 'project_console_logging_level' not in cc_cfg.keys() # it was skipped + assert 'project_file_logging_enabled' not in cc_cfg.keys() # it was skipped + assert 'project_file_logging_level' not in cc_cfg.keys() # it was skipped + assert cc_cfg['github_username'] == 'eruber' + + +@pytest.mark.usefixtures('clean_system') +def test_load_context_defaults_skips_no_branch(): + """ + Test that if_no_skip_to and if_yes_skip_to do not branch and do not + skip variables. + """ + cc = load_cookiecutter('tests/test-context/cookiecutter_skips_2.json') + + cc_cfg = context.load_context(cc['cookiecutter_skips_2'], no_input=True) + + assert cc_cfg['project_configuration_enabled'] is True + assert cc_cfg['project_config_format'] == 'toml' # not skipped + + assert cc_cfg['project_uses_existing_logging_facilities'] is False + assert cc_cfg['project_logging_enabled'] is True # not skipped + assert cc_cfg['project_console_logging_enabled'] is True # not skipped + assert cc_cfg['project_console_logging_level'] == 'WARN' # not skipped + assert cc_cfg['project_file_logging_enabled'] is True # not skipped + + assert 'project_file_logging_level' not in cc_cfg.keys() # do_if skipped + + assert cc_cfg['github_username'] == 'eruber' + + +@pytest.mark.usefixtures('clean_system') +def test_load_context_defaults_skips_unknown_variable_name_warning(caplog): + """ + Test that a warning is issued if a variable.name specified in a skip_to + directive is not in the variable list. + """ + cc = load_cookiecutter('tests/test-context/cookiecutter_skips_3.json') + + cc_cfg = context.load_context(cc['cookiecutter_skips_3'], no_input=True) + + assert cc_cfg['project_uses_existing_logging_facilities'] is False + assert 'project_logging_enabled' not in cc_cfg.keys() # it was skipped + assert 'project_console_logging_enabled' not in cc_cfg.keys() # it was skipped + assert 'project_console_logging_level' not in cc_cfg.keys() # it was skipped + assert 'project_file_logging_enabled' not in cc_cfg.keys() # it was skipped + assert 'project_file_logging_level' not in cc_cfg.keys() # it was skipped + assert 'github_username' not in cc_cfg.keys() # it was skipped + + for record in caplog.records: + assert record.levelname == 'WARNING' + + assert ( + "Processed all variables, but skip_to_variable_name 'this_variable_name_is_not_in_the_list' was never found." + in caplog.text + ) + + +def test_prompt_string(mocker): + + EXPECTED_VALUE = 'Input String' + + mock_prompt = mocker.patch( + 'cookiecutter.prompt.click.prompt', + autospec=True, + return_value=EXPECTED_VALUE, + ) + + m = mocker.Mock() + m.side_effect = context.Variable + v = m.side_effect(name='name', default='', prompt='Enter Name', hide_input=False) + + r = context.prompt_string(v, default='Alpha') + + assert mock_prompt.call_args == mocker.call( + v.prompt, + default='Alpha', + hide_input=v.hide_input, + type=click.STRING, + ) + + assert r == EXPECTED_VALUE + + +def test_prompt_bool(mocker): + + EXPECTED_VALUE = True + + mock_prompt = mocker.patch( + 'cookiecutter.prompt.click.prompt', + autospec=True, + return_value=EXPECTED_VALUE, + ) + + m = mocker.Mock() + m.side_effect = context.Variable + v = m.side_effect( + name='flag', default=False, prompt='Enter a Flag', hide_input=False + ) + + r = context.prompt_boolean(v, default=False) + + assert mock_prompt.call_args == mocker.call( + v.prompt, + default=False, + hide_input=v.hide_input, + type=click.BOOL, + ) + + assert r # EXPECTED_VALUE + + +def test_prompt_int(mocker): + + EXPECTED_VALUE = 777 + + mock_prompt = mocker.patch( + 'cookiecutter.prompt.click.prompt', + autospec=True, + return_value=EXPECTED_VALUE, + ) + + m = mocker.Mock() + m.side_effect = context.Variable + v = m.side_effect(name='port', default=1000, prompt='Enter Port', hide_input=False) + + r = context.prompt_int(v, default=1000) + + assert mock_prompt.call_args == mocker.call( + v.prompt, + default=1000, + hide_input=v.hide_input, + type=click.INT, + ) + + assert r == EXPECTED_VALUE + + +def test_prompt_float(mocker): + + EXPECTED_VALUE = 3.14 + + mock_prompt = mocker.patch( + 'cookiecutter.prompt.click.prompt', + autospec=True, + return_value=EXPECTED_VALUE, + ) + + m = mocker.Mock() + m.side_effect = context.Variable + v = m.side_effect(name='PI', default=3.0, prompt='Enter PI', hide_input=False) + + r = context.prompt_float(v, default=3.0) + + assert mock_prompt.call_args == mocker.call( + v.prompt, + default=3.0, + hide_input=v.hide_input, + type=click.FLOAT, + ) + + assert r == EXPECTED_VALUE + + +def test_prompt_uuid(mocker): + + EXPECTED_VALUE = '931ef56c3e7b45eea0427bac386f0a98' + + mock_prompt = mocker.patch( + 'cookiecutter.prompt.click.prompt', + autospec=True, + return_value=EXPECTED_VALUE, + ) + + m = mocker.Mock() + m.side_effect = context.Variable + v = m.side_effect( + name='uuid', default=None, prompt='Enter a UUID', hide_input=False + ) + + r = context.prompt_uuid(v, default=None) + + assert mock_prompt.call_args == mocker.call( + v.prompt, + default=None, + hide_input=v.hide_input, + type=click.UUID, + ) + + assert r == EXPECTED_VALUE + + +def test_prompt_json(monkeypatch, mocker): + + EXPECTED_VALUE = '{"port": 67888, "colors": ["red", "green", "blue"]}' + + mocker.patch( + 'click.termui.visible_prompt_func', + autospec=True, + return_value=EXPECTED_VALUE, + ) + m = mocker.Mock() + m.side_effect = context.Variable + v = m.side_effect( + name='json', default=None, prompt='Enter Config', hide_input=False + ) + + r = context.prompt_json(v, default=None) + + assert r == {"port": 67888, "colors": ["red", "green", "blue"]} + + +def test_prompt_json_bad_json_decode_click_asks_again(mocker, capsys): + + EXPECTED_BAD_VALUE = '{"port": 67888, "colors": ["red", "green", "blue"}' + EXPECTED_GOOD_VALUE = '{"port": 67888, "colors": ["red", "green", "blue"]}' + + mocker.patch( + 'click.termui.visible_prompt_func', + autospec=True, + side_effect=[EXPECTED_BAD_VALUE, EXPECTED_GOOD_VALUE], + ) + m = mocker.Mock() + m.side_effect = context.Variable + v = m.side_effect( + name='json', default=None, prompt='Enter Config', hide_input=False + ) + + r = context.prompt_json(v, default=None) + + out, err = capsys.readouterr() + assert 'Error: Unable to decode to JSON.' in out + assert r == {"port": 67888, "colors": ["red", "green", "blue"]} + + +def test_prompt_json_default(mocker): + EXPECTED_VALUE = 'default' + + cfg = '{"port": 67888, "colors": ["red", "green", "blue"]}' + + mock_prompt = mocker.patch( + 'cookiecutter.prompt.click.prompt', + autospec=True, + return_value=EXPECTED_VALUE, + ) + + m = mocker.Mock() + m.side_effect = context.Variable + v = m.side_effect( + name='json', default=None, prompt='Enter Config', hide_input=False + ) + + r = context.prompt_json(v, default=cfg) + + assert mock_prompt.call_args == mocker.call( + v.prompt, + default='default', + hide_input=v.hide_input, + type=click.STRING, + value_proc=mocker.ANY, + ) + + assert r == cfg + + +def test_prompt_yes_no_default_no(mocker): + + EXPECTED_VALUE = 'y' + + mock_prompt = mocker.patch( + 'cookiecutter.prompt.click.prompt', + autospec=True, + return_value=EXPECTED_VALUE, + ) + + m = mocker.Mock() + m.side_effect = context.Variable + v = m.side_effect( + name='enable_docs', default='n', prompt='Enable docs', hide_input=False + ) + + r = context.prompt_yes_no(v, default=False) + + assert mock_prompt.call_args == mocker.call( + v.prompt, + default='n', + hide_input=v.hide_input, + type=click.BOOL, + ) + + assert r # EXPECTED_VALUE + + +def test_prompt_yes_no_default_yes(mocker): + + EXPECTED_VALUE = 'y' + + mock_prompt = mocker.patch( + 'cookiecutter.prompt.click.prompt', + autospec=True, + return_value=EXPECTED_VALUE, + ) + + m = mocker.Mock() + m.side_effect = context.Variable + v = m.side_effect( + name='enable_docs', default='y', prompt='Enable docs', hide_input=False + ) + + r = context.prompt_yes_no(v, default=True) + + assert mock_prompt.call_args == mocker.call( + v.prompt, + default='y', + hide_input=v.hide_input, + type=click.BOOL, + ) + + assert r # EXPECTED_VALUE + + +def test_prompt_choice(mocker): + + LICENSES = ['ISC', 'MIT', 'BSD3'] + + DEFAULT_LICENSE = 'ISC' + + EXPECTED_VALUE = '2' + EXPECTED_LICENSE = 'MIT' + + mocker.patch( + 'cookiecutter.prompt.click.prompt', + autospec=True, + return_value=EXPECTED_VALUE, + ) + + m = mocker.Mock() + m.side_effect = context.Variable + v = m.side_effect( + name='license', + default=DEFAULT_LICENSE, + choices=LICENSES, + prompt='Pick a License', + hide_input=False, + ) + + r = context.prompt_choice(v, default=DEFAULT_LICENSE) + + assert r == EXPECTED_LICENSE + + +def test_variable_invalid_type_exception(): + + with pytest.raises(ValueError) as excinfo: + context.Variable(name='badtype', default=None, type='color') + + assert 'Variable: badtype has an invalid type color' in str(excinfo.value) + + +def test_variable_invalid_default_choice(): + + CHOICES = ['green', 'red', 'blue', 'yellow'] + + with pytest.raises(ValueError) as excinfo: + context.Variable( + name='badchoice', default='purple', type='string', choices=CHOICES + ) + + assert 'Variable: badchoice has an invalid default value purple for choices: {choices}'.format( + choices=CHOICES + ) in str( + excinfo.value + ) + + +def test_variable_invalid_validation_control_flag_is_logged_and_removed(caplog): + + with caplog.at_level(logging.INFO): + v = context.Variable( + 'module_name', + "{{cookiecutter.plugin_name|lower|replace('-','_')}}", + prompt="Please enter a name for your base python module", + type='string', + validation='^[a-z_]+$', + validation_flags=[ + 'ignorecase', + 'forget', + ], + hide_input=True, + ) + + for record in caplog.records: + assert record.levelname == 'WARNING' + + assert ( + "Variable: module_name - Ignoring unkown RegEx validation Control Flag named 'forget'" + in caplog.text + ) + + assert v.validation_flag_names == ['ignorecase'] + + +def test_variable_validation_compile_exception(): + + VAR_NAME = 'module_name' + BAD_REGEX_STRING = '^[a-z_+$' # Missing a closing square-bracket (]) + + with pytest.raises(ValueError) as excinfo: + context.Variable( + VAR_NAME, + "{{cookiecutter.plugin_name|lower|replace('-','_')}}", + prompt="Please enter a name for your base python module", + type='string', + validation=BAD_REGEX_STRING, + validation_flags=['ignorecase'], + hide_input=True, + ) + + assert "Variable: {var_name} - Validation Setup Error: Invalid RegEx '{value}' - does not compile - ".format( + var_name=VAR_NAME, value=BAD_REGEX_STRING + ) in str( + excinfo.value + ) + + +def test_variable_forces_no_prompt_for_private_variable_names(): + v = context.Variable( + '_private_variable_name', + "{{cookiecutter.plugin_name|lower|replace('-','_')}}", + prompt="Please enter a name for your base python module", + prompt_user=True, + type='string', + validation='^[a-z_]+$', + validation_flags=['ignorecase'], + hide_input=True, + ) + + assert v.prompt_user is False + + +def test_variable_repr(): + + v = context.Variable( + 'module_name', + "{{cookiecutter.plugin_name|lower|replace('-','_')}}", + prompt="Please enter a name for your base python module", + type='string', + validation='^[a-z_]+$', + validation_flags=['ignorecase'], + hide_input=True, + ) + + assert repr(v) == "" + + +def test_variable_str(): + + v = context.Variable( + 'module_name', + "{{cookiecutter.plugin_name|lower|replace('-','_')}}", + prompt="Please enter a name for your base python module", + type='string', + validation='^[a-z_]+$', + validation_flags=['ignorecase'], + hide_input=True, + ) + + str_v = str(v) + assert ':' in str_v + assert "name='module_name'" in str_v + assert "default='{{cookiecutter.plugin_name|lower|replace('-','_')}}'" in str_v + assert "description='None'" in str_v + assert "prompt='Please enter a name for your base python module'" in str_v + assert "hide_input='True'" in str_v + assert "var_type='string'" in str_v + assert "skip_if=''" in str_v + assert "prompt_user='True'" in str_v + assert "choices='[]'" in str_v + assert "validation='^[a-z_]+$'" in str_v + assert "validation_flag_names='['ignorecase']'" in str_v + assert ( + "validation_flags='2'" in str_v or "validation_flags='re.IGNORECASE'" in str_v + ) + + if sys.version_info >= (3, 4): + assert "validate='re.compile('^[a-z_]+$', re.IGNORECASE)'" in str(v) + else: + assert "validate='<_sre.SRE_Pattern object at" in str(v) + + +def test_variable_option_raise_invalid_type_value_error(): + + VAR_NAME = 'module_name' + OPT_VALUE_OF_INCORRECT_TYPE = 12 # should be a string + + with pytest.raises(ValueError) as excinfo: + context.Variable( + VAR_NAME, + "{{cookiecutter.plugin_name|lower|replace('-','_')}}", + prompt="Please enter a name for your base python module", + type='string', + validation=OPT_VALUE_OF_INCORRECT_TYPE, + validation_flags=['ignorecase'], + hide_input=True, + ) + + msg = "Variable: '{var_name}' Option: 'validation' requires a value of type str, but has a value of: {value}" + assert msg.format(var_name=VAR_NAME, value=OPT_VALUE_OF_INCORRECT_TYPE) in str( + excinfo.value + ) + + +def test_cookiecutter_template_repr(): + # name, cookiecutter_version, variables, **info + + cct = context.CookiecutterTemplate( + 'cookiecutter_template_repr_test', cookiecutter_version='2.0.0', variables=[] + ) + + assert repr(cct) == "" + + +def test_load_context_with_input_choices(mocker): + cc = load_cookiecutter('tests/test-context/cookiecutter_choices.json') + + INPUT_1 = 'E.R. Uber' + INPUT_2 = 'eruber@gmail.com' + INPUT_3 = '2' # 'MIT' + mocker.patch( + 'click.termui.visible_prompt_func', + autospec=True, + side_effect=[INPUT_1, INPUT_2, INPUT_3], + ) + + cc_cfg = context.load_context(cc['cookiecutter_choices'], no_input=False) + + assert cc_cfg['full_name'] == INPUT_1 + assert cc_cfg['email'] == INPUT_2 + assert cc_cfg['license'] == 'MIT' + + +def test_load_context_with_input_choices_no_verbose(mocker): + cc = load_cookiecutter('tests/test-context/cookiecutter_choices.json') + + INPUT_1 = 'E.R. Uber' + INPUT_2 = 'eruber@gmail.com' + INPUT_3 = '2' # 'MIT' + mocker.patch( + 'click.termui.visible_prompt_func', + autospec=True, + side_effect=[INPUT_1, INPUT_2, INPUT_3], + ) + + cc_cfg = context.load_context( + cc['cookiecutter_choices'], no_input=False, verbose=False + ) + + assert cc_cfg['full_name'] == INPUT_1 + assert cc_cfg['email'] == INPUT_2 + assert cc_cfg['license'] == 'MIT' + + +def test_load_context_with_input_with_validation_success(mocker): + cc = load_cookiecutter('tests/test-context/cookiecutter_val_success.json') + + INPUT_1 = 'Image Module Maker' + INPUT_2 = '' + mocker.patch( + 'click.termui.visible_prompt_func', + autospec=True, + side_effect=[INPUT_1, INPUT_2], + ) + + logger.debug(cc) + + cc_cfg = context.load_context(cc['cookiecutter_val_success'], no_input=False) + + assert cc_cfg['project_name'] == INPUT_1 + assert cc_cfg['module_name'] == 'image_module_maker' + + +def test_load_context_with_input_with_validation_failure(mocker, capsys): + cc = load_cookiecutter('tests/test-context/cookiecutter_val_failure.json') + + INPUT_1 = '6 Debug Shell' + INPUT_2 = '' + INPUT_3 = 'debug_shell' + mocker.patch( + 'click.termui.visible_prompt_func', + autospec=True, + side_effect=[INPUT_1, INPUT_2, INPUT_3], + ) + + cc_cfg = context.load_context(cc['cookiecutter_val_failure'], no_input=False) + + out, err = capsys.readouterr() + + msg = "Input validation failure against regex: '^[a-z_]+$', try again!" + assert msg in out + + assert cc_cfg['project_name'] == INPUT_1 + assert cc_cfg['module_name'] == INPUT_3 + + +def test_load_context_with_input_with_validation_failure_msg(mocker, capsys): + cc = load_cookiecutter('tests/test-context/cookiecutter_val_failure_msg.json') + + INPUT_1 = '6 Debug Shell' + INPUT_2 = '' + INPUT_3 = 'debug_shell' + mocker.patch( + 'click.termui.visible_prompt_func', + autospec=True, + side_effect=[INPUT_1, INPUT_2, INPUT_3], + ) + + cc_cfg = context.load_context(cc['cookiecutter_val_failure_msg'], no_input=False) + + out, err = capsys.readouterr() + + msg = "Input validation failure against regex: '^[a-z_]+$', try again!" + assert msg in out + + msg2 = "Really, you couldn't get this correct the first time?" + assert msg2 in out + + assert cc_cfg['project_name'] == INPUT_1 + assert cc_cfg['module_name'] == INPUT_3 + + +def test_specify_if_yes_skip_to_without_yes_no_type(): + """ + Test ValueError is raised when a variable specifies an if_yes_skip_to + field and the variable type is not 'yes+no' + """ + with pytest.raises(ValueError) as excinfo: + context.Variable( + name='author', default='JKR', type='string', if_yes_skip_to='roman' + ) + + assert ( + "Variable: 'author' specifies 'if_yes_skip_to' field, but variable not of type 'yes_no'" + in str(excinfo.value) + ) + + +def test_specify_if_no_skip_to_without_yes_no_type(): + """ + Test ValueError is raised when a variable specifies an if_no_skip_to + field and the variable type is not 'yes+no' + """ + with pytest.raises(ValueError) as excinfo: + context.Variable( + name='author', default='JKR', type='string', if_no_skip_to='roman' + ) + + assert ( + "Variable: 'author' specifies 'if_no_skip_to' field, but variable not of type 'yes_no'" + in str(excinfo.value) + ) diff --git a/tests/test_generate_context_v2.py b/tests/test_generate_context_v2.py new file mode 100644 index 000000000..1f45f8dc6 --- /dev/null +++ b/tests/test_generate_context_v2.py @@ -0,0 +1,1620 @@ +# -*- coding: utf-8 -*- +# flake8: noqa +""" +test_generate_convext_v2 +------------------------ + +Tests associated with processing v2 context syntax in the +`cookiecutter.generate` module. +""" + +from __future__ import unicode_literals +import pytest + +from collections import OrderedDict + +from cookiecutter import generate + + +def context_data(): + context = ( + {'context_file': 'tests/test-generate-context-v2/test.json'}, + { + "test": OrderedDict( + [ + ("name", "cookiecutter-pytest-plugin"), + ("cookiecutter_version", "2.0.0"), + ( + "variables", + [ + OrderedDict( + [ + ("name", "full_name"), + ("default", "J. Paul Getty"), + ("prompt", "What's your full name?"), + ( + "description", + "Please enter your full name. It will be displayed on the README file and used for the PyPI package definition.", + ), + ("type", "string"), + ] + ), + OrderedDict( + [ + ("name", "email"), + ("default", "jpg@rich.de"), + ("prompt", "What's your email?"), + ( + "description", + "Please enter an email address for the meta information in setup.py.", + ), + ("type", "string"), + ] + ), + ], + ), + ] + ) + }, + ) + + context_with_default = ( + { + 'context_file': 'tests/test-generate-context-v2/test.json', + 'default_context': { + 'full_name': 'James Patrick Morgan', + 'this_key_ignored': 'not_in_context', + }, + }, + { + "test": OrderedDict( + [ + ("name", "cookiecutter-pytest-plugin"), + ("cookiecutter_version", "2.0.0"), + ( + "variables", + [ + OrderedDict( + [ + ("name", "full_name"), + ("default", "James Patrick Morgan"), + ("prompt", "What's your full name?"), + ( + "description", + "Please enter your full name. It will be displayed on the README file and used for the PyPI package definition.", + ), + ("type", "string"), + ] + ), + OrderedDict( + [ + ("name", "email"), + ("default", "jpg@rich.de"), + ("prompt", "What's your email?"), + ( + "description", + "Please enter an email address for the meta information in setup.py.", + ), + ("type", "string"), + ] + ), + ], + ), + ] + ) + }, + ) + + context_with_extra = ( + { + 'context_file': 'tests/test-generate-context-v2/test.json', + 'extra_context': {'email': 'jpm@chase.bk'}, + }, + { + "test": OrderedDict( + [ + ("name", "cookiecutter-pytest-plugin"), + ("cookiecutter_version", "2.0.0"), + ( + "variables", + [ + OrderedDict( + [ + ("name", "full_name"), + ("default", "J. Paul Getty"), + ("prompt", "What's your full name?"), + ( + "description", + "Please enter your full name. It will be displayed on the README file and used for the PyPI package definition.", + ), + ("type", "string"), + ] + ), + OrderedDict( + [ + ("name", "email"), + ("default", "jpm@chase.bk"), + ("prompt", "What's your email?"), + ( + "description", + "Please enter an email address for the meta information in setup.py.", + ), + ("type", "string"), + ] + ), + ], + ), + ] + ) + }, + ) + + context_with_default_and_extra = ( + { + 'context_file': 'tests/test-generate-context-v2/test.json', + 'default_context': {'full_name': 'Alpha Gamma Five'}, + 'extra_context': {'email': 'agamma5@universe.open'}, + }, + { + "test": OrderedDict( + [ + ("name", "cookiecutter-pytest-plugin"), + ("cookiecutter_version", "2.0.0"), + ( + "variables", + [ + OrderedDict( + [ + ("name", "full_name"), + ("default", "Alpha Gamma Five"), + ("prompt", "What's your full name?"), + ( + "description", + "Please enter your full name. It will be displayed on the README file and used for the PyPI package definition.", + ), + ("type", "string"), + ] + ), + OrderedDict( + [ + ("name", "email"), + ("default", "agamma5@universe.open"), + ("prompt", "What's your email?"), + ( + "description", + "Please enter an email address for the meta information in setup.py.", + ), + ("type", "string"), + ] + ), + ], + ), + ] + ) + }, + ) + + context_choices_with_default = ( + { + 'context_file': 'tests/test-generate-context-v2/test_choices.json', + 'default_context': {'license': 'Apache2'}, + }, + { + "test_choices": OrderedDict( + [ + ("name", "cookiecutter-pytest-plugin"), + ("cookiecutter_version", "2.0.0"), + ( + "variables", + [ + OrderedDict( + [ + ("name", "license"), + ("default", "Apache2"), + ( + "choices", + [ + "Apache2", + "MIT", + "BSD3", + "GNU-GPL3", + "Mozilla2", + ], + ), + ] + ) + ], + ), + ] + ) + }, + ) + + context_choices_with_extra = ( + { + 'context_file': 'tests/test-generate-context-v2/test_choices.json', + 'default_context': {'license': 'Apache2'}, + 'extra_context': {'license': 'MIT'}, + }, + { + "test_choices": OrderedDict( + [ + ("name", "cookiecutter-pytest-plugin"), + ("cookiecutter_version", "2.0.0"), + ( + "variables", + [ + OrderedDict( + [ + ("name", "license"), + ("default", "MIT"), + ( + "choices", + [ + "MIT", + "Apache2", + "BSD3", + "GNU-GPL3", + "Mozilla2", + ], + ), + ] + ) + ], + ), + ] + ) + }, + ) + + context_choices_with_default_not_in_choices = ( + { + 'context_file': 'tests/test-generate-context-v2/test.json', + 'default_context': {'orientation': 'landscape'}, + }, + { + "test": OrderedDict( + [ + ("name", "cookiecutter-pytest-plugin"), + ("cookiecutter_version", "2.0.0"), + ( + "variables", + [ + OrderedDict( + [ + ("name", "full_name"), + ("default", "J. Paul Getty"), + ("prompt", "What's your full name?"), + ( + "description", + "Please enter your full name. It will be displayed on the README file and used for the PyPI package definition.", + ), + ("type", "string"), + ] + ), + OrderedDict( + [ + ("name", "email"), + ("default", "jpg@rich.de"), + ("prompt", "What's your email?"), + ( + "description", + "Please enter an email address for the meta information in setup.py.", + ), + ("type", "string"), + ] + ), + ], + ), + ] + ) + }, + ) + yield context + yield context_with_default + yield context_with_extra + yield context_with_default_and_extra + yield context_choices_with_default + yield context_choices_with_extra + yield context_choices_with_default_not_in_choices + + +def context_data_misses(): + context_choices_with_default = ( + { + 'context_file': 'tests/test-generate-context-v2/test_choices-miss.json', + 'default_context': {'license': 'Cherokee'}, + }, + { + "test_choices-miss": OrderedDict( + [ + ("name", "test_choices-miss"), + ("cookiecutter_version", "2.0.0"), + ( + "variables", + [ + OrderedDict( + [ + ("name", "license"), + ("default", "Apache2"), + ( + "choices", + [ + "MIT", + "BSD3", + "GNU-GPL3", + "Apache2", + "Mozilla2", + ], + ), + ] + ) + ], + ), + ] + ) + }, + ) + + context_choices_with_extra = ( + { + 'context_file': 'tests/test-generate-context-v2/test_choices-miss.json', + 'extra_context': {'license': 'MIT'}, + }, + { + "test_choices-miss": OrderedDict( + [ + ("name", "test_choices-miss"), + ("cookiecutter_version", "2.0.0"), + ( + "variables", + [ + OrderedDict( + [ + ("name", "license"), + ("default", "MIT"), + ( + "choices", + [ + "MIT", + "BSD3", + "GNU-GPL3", + "Apache2", + "Mozilla2", + ], + ), + ] + ) + ], + ), + ] + ) + }, + ) + + yield context_choices_with_default + yield context_choices_with_extra + + +def context_data_value_errors(): + context_choices_with_default_value_error = ( + { + 'context_file': 'tests/test-generate-context-v2/test_choices.json', + 'default_context': [{'license': 'MIT'}], + }, + { + "test_choices": OrderedDict( + [ + ("name", "cookiecutter-pytest-plugin"), + ("cookiecutter_version", "2.0.0"), + ( + "variables", + [ + OrderedDict( + [ + ("name", "license"), + ("default", "MIT"), + ( + "choices", + [ + "MIT", + "Apache2", + "BSD3", + "GNU-GPL3", + "Mozilla2", + ], + ), + ] + ) + ], + ), + ] + ) + }, + False, + ) + context_choices_with_extra_value_error = ( + { + 'context_file': 'tests/test-generate-context-v2/test_choices.json', + 'default_context': {'license': 'Apache2'}, + 'extra_context': [{'license': 'MIT'}], + }, + { + "test_choices": OrderedDict( + [ + ("name", "cookiecutter-pytest-plugin"), + ("cookiecutter_version", "2.0.0"), + ( + "variables", + [ + OrderedDict( + [ + ("name", "license"), + ("default", "MIT"), + ( + "choices", + [ + "MIT", + "Apache2", + "BSD3", + "GNU-GPL3", + "Mozilla2", + ], + ), + ] + ) + ], + ), + ] + ) + }, + True, + ) + yield context_choices_with_default_value_error + yield context_choices_with_extra_value_error + + +@pytest.mark.usefixtures('clean_system') +@pytest.mark.parametrize('input_params, expected_context', context_data()) +def test_generate_context(input_params, expected_context): + """ + Test the generated context for several input parameters against the + according expected context. + """ + generated_context = generate.generate_context(**input_params) + assert generated_context == expected_context + + +@pytest.mark.usefixtures('clean_system') +@pytest.mark.parametrize('input_params, expected_context', context_data_misses()) +def test_generate_context_misses(input_params, expected_context): + """ + Test the generated context for several input parameters against the + according expected context. + """ + generated_context = generate.generate_context(**input_params) + assert generated_context == expected_context + + +@pytest.mark.usefixtures('clean_system') +@pytest.mark.parametrize( + 'input_params, expected_context, raise_exception', context_data_value_errors() +) +def test_generate_context_value_error(input_params, expected_context, raise_exception): + """ + Test the generated context for several input parameters against the + according expected context. + """ + if raise_exception: + with pytest.raises(ValueError) as excinfo: + generate.generate_context(**input_params) + else: + generate.generate_context(**input_params) + + +@pytest.mark.usefixtures('clean_system') +def test_generate_context_extra_ctx_invalid(): + """ + Test error condition when extra context is not a dictionary or a list + of dictionaries. + """ + + with pytest.raises(ValueError) as excinfo: + generate.generate_context( + context_file='tests/test-generate-context-v2/test.json', + default_context=None, + extra_context='should_be_a_list_or_a_dictionary', + ) + + msg = "Extra context must be a dictionary or a list of dictionaries!" + assert msg in str(excinfo.value) + + +@pytest.mark.usefixtures('clean_system') +def test_generate_context_extra_ctx_list_item_not_dict(): + """ + Test error condition when extra context is a list, but not a list that + contains a dictionary. + """ + xtra_context = ['a_string', 'here_too'] + with pytest.raises(ValueError) as excinfo: + generate.generate_context( + context_file='tests/test-generate-context-v2/test.json', + default_context=None, + extra_context=xtra_context, + ) + + msg = "Extra context list item 'a_string' is of type str, should be a dictionary." + assert msg in str(excinfo.value) + + +@pytest.mark.usefixtures('clean_system') +def test_generate_context_extra_ctx_list_item_dict_missing_name_field(): + """ + Test error condition when extra context is a list, but not a list that + contains a dictionary. + """ + xtra_context = [ + { + "shouldbename": "author_name", + "default": "Robert Lewis", + "prompt": "What's the author's name?", + "description": "Please enter the author's full name.", + "type": "string", + } + ] + with pytest.raises(ValueError) as excinfo: + generate.generate_context( + context_file='tests/test-generate-context-v2/test.json', + default_context=None, + extra_context=xtra_context, + ) + + msg = "is missing a 'name' key." + assert msg in str(excinfo.value) + + +@pytest.mark.usefixtures('clean_system') +def test_generate_context_extra_ctx_list_item_dict_no_name_field_match(): + """ + Test error condition when extra context is a list, but not a list that + contains a dictionary. + """ + xtra_context = [ + { + "name": "author_name", + "default": "Robert Lewis", + "prompt": "What's the author's name?", + "description": "Please enter the author's full name.", + "type": "string", + } + ] + with pytest.raises(ValueError) as excinfo: + generate.generate_context( + context_file='tests/test-generate-context-v2/test.json', + default_context=None, + extra_context=xtra_context, + ) + + msg = "No variable found in context whose name matches extra context name 'author_name'" + assert msg in str(excinfo.value) + + +def gen_context_data_inputs_expected(): + # Extra field ignored + context_with_valid_extra_0 = ( + { + 'context_file': 'tests/test-generate-context-v2/test.json', + 'extra_context': [ + { + 'name': 'email', + 'default': 'miles.davis@jazz.gone', + 'description': 'Enter jazzy email...', + 'extra_field': 'extra_field_value', + } + ], + }, + { + "test": OrderedDict( + [ + ("name", "cookiecutter-pytest-plugin"), + ("cookiecutter_version", "2.0.0"), + ( + "variables", + [ + OrderedDict( + [ + ("name", "full_name"), + ("default", "J. Paul Getty"), + ("prompt", "What's your full name?"), + ( + "description", + "Please enter your full name. It will be displayed on the README file and used for the PyPI package definition.", + ), + ("type", "string"), + ] + ), + OrderedDict( + [ + ("name", "email"), + ("default", "miles.davis@jazz.gone"), + ("prompt", "What's your email?"), + ("description", "Enter jazzy email..."), + ("type", "string"), + ] + ), + ], + ), + ] + ) + }, + ) + # Empty extra context precipitates no ill effect + context_with_valid_extra_1 = ( + { + 'context_file': 'tests/test-generate-context-v2/representative.json', + 'extra_context': [] + # 'extra_context': [ + # { + # 'name': 'email', + # 'default': 'miles.davis@jazz.gone', + # 'description': 'Enter jazzy email...', + # 'extra_field': 'extra_field_value', + # } + # ] + }, + { + "representative": OrderedDict( + [ + ("name", "cc-representative"), + ("cookiecutter_version", "2.0.0"), + ( + "variables", + [ + OrderedDict( + [ + ("name", "director_credit"), + ("default", True), + ( + "prompt", + "Is there a director credit on this film?", + ), + ( + "description", + "Directors take credit for most of their films, usually...", + ), + ("type", "boolean"), + ] + ), + OrderedDict( + [ + ("name", "director_name"), + ("default", "Allan Smithe"), + ("prompt", "What's the Director's full name?"), + ("prompt_user", True), + ( + "description", + "The default director is not proud of their work, we hope you are.", + ), + ("hide_input", False), + ( + "choices", + [ + "Allan Smithe", + "Ridley Scott", + "Victor Fleming", + "John Houston", + ], + ), + ("validation", "^[a-z][A-Z]+$"), + ("validation_flags", ["verbose", "ascii"]), + ( + "skip_if", + "{{cookiecutter.director_credit == False}}", + ), + ("type", "string"), + ] + ), + ], + ), + ] + ) + }, + ) + + # Test the ability to change the variable's name field (since it is used + # to identify the variable to be modifed) with extra context and to remove + # a key from the context via the removal token: '<>' + context_with_valid_extra_2 = ( + { + 'context_file': 'tests/test-generate-context-v2/representative-director.json', + 'extra_context': [ + { + 'name': 'director_credit::producer_credit', + 'prompt': 'Is there a producer credit on this film?', + 'description': 'There are usually a lot of producers...', + }, + { + 'name': 'director_name', + 'skip_if': '<>', + }, + ], + }, + { + "representative-director": OrderedDict( + [ + ("name", "cc-representative"), + ("cookiecutter_version", "2.0.0"), + ( + "variables", + [ + OrderedDict( + [ + ("name", "producer_credit"), + ("default", True), + ( + "prompt", + "Is there a producer credit on this film?", + ), + ( + "description", + "There are usually a lot of producers...", + ), + ("type", "boolean"), + ] + ), + OrderedDict( + [ + ("name", "director_exists"), + ("default", False), + ("prompt", "Is there a Director?"), + ("prompt_user", True), + ( + "description", + "The director exists.", + ), + ("hide_input", False), + ( + "choices", + [ + True, + False, + ], + ), + ("type", "boolean"), + ] + ), + OrderedDict( + [ + ("name", "director_name"), + ("default", "Allan Smithe"), + ("prompt", "What's the Director's full name?"), + ("prompt_user", True), + ( + "description", + "The default director is not proud of their work, we hope you are.", + ), + ("hide_input", False), + ( + "choices", + [ + "Allan Smithe", + "Ridley Scott", + "Victor Fleming", + "John Houston", + ], + ), + ("validation", "^[a-z][A-Z]+$"), + ("validation_flags", ["verbose", "ascii"]), + ("type", "string"), + ] + ), + ], + ), + ] + ) + }, + ) + # Test the ability to change the variable's name field (since it is used + # to identify the variable to be modifed) with extra context and to also + # test that any other references in other variables that might use the + # original variable name get updated as well. + context_with_valid_extra_2_B = ( + { + 'context_file': 'tests/test-generate-context-v2/representative_2B.json', + 'extra_context': [ + { + 'name': 'director_credit::producer_credit', + 'prompt': 'Is there a producer credit on this film?', + 'description': 'There are usually a lot of producers...', + }, + ], + }, + { + "representative_2B": OrderedDict( + [ + ("name", "cc-representative"), + ("cookiecutter_version", "2.0.0"), + ( + "variables", + [ + OrderedDict( + [ + ("name", "producer_credit"), + ("default", True), + ( + "prompt", + "Is there a producer credit on this film?", + ), + ( + "description", + "There are usually a lot of producers...", + ), + ("type", "boolean"), + ] + ), + OrderedDict( + [ + ("name", "director_name"), + ("default", "Allan Smithe"), + ("prompt", "What's the Director's full name?"), + ("prompt_user", True), + ( + "description", + "The default director is not proud of their work, we hope you are.", + ), + ("hide_input", False), + ( + "choices", + [ + "Allan Smithe", + "Ridley Scott", + "Victor Fleming", + "John Houston", + "{{cookiecutter.producer_credit}}", + ], + ), + ("validation", "^[a-z][A-Z]+$"), + ("validation_flags", ["verbose", "ascii"]), + ( + "skip_if", + "{{cookiecutter.producer_credit == False}}", + ), + ("type", "string"), + ] + ), + ], + ), + ] + ) + }, + ) + + # Test changing variable's name field value, default field, prompt field, + # and changing the type + context_with_valid_extra_3 = ( + { + 'context_file': 'tests/test-generate-context-v2/representative.json', + 'extra_context': [ + { + 'name': 'director_credit::producer_credits', + 'default': 2, + 'prompt': 'How many producers does this film have?', + 'description': 'There are usually a lot of producers...', + 'type': "int", + } + ], + }, + { + "representative": OrderedDict( + [ + ("name", "cc-representative"), + ("cookiecutter_version", "2.0.0"), + ( + "variables", + [ + OrderedDict( + [ + ("name", "producer_credits"), + ("default", 2), + ( + "prompt", + "How many producers does this film have?", + ), + ( + "description", + "There are usually a lot of producers...", + ), + ("type", "int"), + ] + ), + OrderedDict( + [ + ("name", "director_name"), + ("default", "Allan Smithe"), + ("prompt", "What's the Director's full name?"), + ("prompt_user", True), + ( + "description", + "The default director is not proud of their work, we hope you are.", + ), + ("hide_input", False), + ( + "choices", + [ + "Allan Smithe", + "Ridley Scott", + "Victor Fleming", + "John Houston", + ], + ), + ("validation", "^[a-z][A-Z]+$"), + ("validation_flags", ["verbose", "ascii"]), + ( + "skip_if", + "{{cookiecutter.producer_credits == False}}", + ), + ("type", "string"), + ] + ), + ], + ), + ] + ) + }, + ) + # Test changing choices field without changing the default, but default + # does not change because the first position in choices matches default + context_with_valid_extra_4 = ( + { + 'context_file': 'tests/test-generate-context-v2/representative.json', + 'extra_context': [ + { + 'name': 'director_name', + 'choices': [ + 'Allan Smithe', + 'Ridley Scott', + 'Victor Fleming', + 'John Houston', + 'John Ford', + 'Billy Wilder', + ], + } + ], + }, + { + "representative": OrderedDict( + [ + ("name", "cc-representative"), + ("cookiecutter_version", "2.0.0"), + ( + "variables", + [ + OrderedDict( + [ + ("name", "director_credit"), + ("default", True), + ( + "prompt", + "Is there a director credit on this film?", + ), + ( + "description", + "Directors take credit for most of their films, usually...", + ), + ("type", "boolean"), + ] + ), + OrderedDict( + [ + ("name", "director_name"), + ("default", "Allan Smithe"), + ("prompt", "What's the Director's full name?"), + ("prompt_user", True), + ( + "description", + "The default director is not proud of their work, we hope you are.", + ), + ("hide_input", False), + ( + "choices", + [ + 'Allan Smithe', + 'Ridley Scott', + 'Victor Fleming', + 'John Houston', + 'John Ford', + 'Billy Wilder', + ], + ), + ("validation", "^[a-z][A-Z]+$"), + ("validation_flags", ["verbose", "ascii"]), + ( + "skip_if", + "{{cookiecutter.director_credit == False}}", + ), + ("type", "string"), + ] + ), + ], + ), + ] + ) + }, + ) + # Test changing choices field and changing the default + context_with_valid_extra_5 = ( + { + 'context_file': 'tests/test-generate-context-v2/representative.json', + 'extra_context': [ + { + 'name': 'director_name', + 'default': 'John Ford', + 'choices': [ + 'Allan Smithe', + 'Ridley Scott', + 'Victor Fleming', + 'John Houston', + 'John Ford', + 'Billy Wilder', + ], + } + ], + }, + { + "representative": OrderedDict( + [ + ("name", "cc-representative"), + ("cookiecutter_version", "2.0.0"), + ( + "variables", + [ + OrderedDict( + [ + ("name", "director_credit"), + ("default", True), + ( + "prompt", + "Is there a director credit on this film?", + ), + ( + "description", + "Directors take credit for most of their films, usually...", + ), + ("type", "boolean"), + ] + ), + OrderedDict( + [ + ("name", "director_name"), + ("default", "John Ford"), + ("prompt", "What's the Director's full name?"), + ("prompt_user", True), + ( + "description", + "The default director is not proud of their work, we hope you are.", + ), + ("hide_input", False), + ( + "choices", + [ + 'John Ford', + 'Allan Smithe', + 'Ridley Scott', + 'Victor Fleming', + 'John Houston', + 'Billy Wilder', + ], + ), + ("validation", "^[a-z][A-Z]+$"), + ("validation_flags", ["verbose", "ascii"]), + ( + "skip_if", + "{{cookiecutter.director_credit == False}}", + ), + ("type", "string"), + ] + ), + ], + ), + ] + ) + }, + ) + # Test changing the default, but not the choices field, yet seeing choices field re-ordered + # to put default value in first location + context_with_valid_extra_6 = ( + { + 'context_file': 'tests/test-generate-context-v2/representative.json', + 'extra_context': [ + { + 'name': 'director_name', + 'default': 'John Ford', + } + ], + }, + { + "representative": OrderedDict( + [ + ("name", "cc-representative"), + ("cookiecutter_version", "2.0.0"), + ( + "variables", + [ + OrderedDict( + [ + ("name", "director_credit"), + ("default", True), + ( + "prompt", + "Is there a director credit on this film?", + ), + ( + "description", + "Directors take credit for most of their films, usually...", + ), + ("type", "boolean"), + ] + ), + OrderedDict( + [ + ("name", "director_name"), + ("default", "John Ford"), + ("prompt", "What's the Director's full name?"), + ("prompt_user", True), + ( + "description", + "The default director is not proud of their work, we hope you are.", + ), + ("hide_input", False), + ( + "choices", + [ + 'John Ford', + 'Allan Smithe', + 'Ridley Scott', + 'Victor Fleming', + 'John Houston', + ], + ), + ("validation", "^[a-z][A-Z]+$"), + ("validation_flags", ["verbose", "ascii"]), + ( + "skip_if", + "{{cookiecutter.director_credit == False}}", + ), + ("type", "string"), + ] + ), + ], + ), + ] + ) + }, + ) + # Test changing choices field without changing the default, but default + # does get changee because the first position in choices field chagned + context_with_valid_extra_7 = ( + { + 'context_file': 'tests/test-generate-context-v2/representative.json', + 'extra_context': [ + { + 'name': 'director_name', + 'choices': [ + 'Billy Wilder', + 'Allan Smithe', + 'Ridley Scott', + 'Victor Fleming', + 'John Houston', + 'John Ford', + ], + } + ], + }, + { + "representative": OrderedDict( + [ + ("name", "cc-representative"), + ("cookiecutter_version", "2.0.0"), + ( + "variables", + [ + OrderedDict( + [ + ("name", "director_credit"), + ("default", True), + ( + "prompt", + "Is there a director credit on this film?", + ), + ( + "description", + "Directors take credit for most of their films, usually...", + ), + ("type", "boolean"), + ] + ), + OrderedDict( + [ + ("name", "director_name"), + ("default", "Billy Wilder"), + ("prompt", "What's the Director's full name?"), + ("prompt_user", True), + ( + "description", + "The default director is not proud of their work, we hope you are.", + ), + ("hide_input", False), + ( + "choices", + [ + 'Billy Wilder', + 'Allan Smithe', + 'Ridley Scott', + 'Victor Fleming', + 'John Houston', + 'John Ford', + ], + ), + ("validation", "^[a-z][A-Z]+$"), + ("validation_flags", ["verbose", "ascii"]), + ( + "skip_if", + "{{cookiecutter.director_credit == False}}", + ), + ("type", "string"), + ] + ), + ], + ), + ] + ) + }, + ) + # Test changing the default value with a value that is not in choices, + # we should see the choice first position get updated. + context_with_valid_extra_8 = ( + { + 'context_file': 'tests/test-generate-context-v2/representative.json', + 'extra_context': [ + { + 'name': 'director_name', + 'default': 'Peter Sellers', + } + ], + }, + { + "representative": OrderedDict( + [ + ("name", "cc-representative"), + ("cookiecutter_version", "2.0.0"), + ( + "variables", + [ + OrderedDict( + [ + ("name", "director_credit"), + ("default", True), + ( + "prompt", + "Is there a director credit on this film?", + ), + ( + "description", + "Directors take credit for most of their films, usually...", + ), + ("type", "boolean"), + ] + ), + OrderedDict( + [ + ("name", "director_name"), + ("default", "Peter Sellers"), + ("prompt", "What's the Director's full name?"), + ("prompt_user", True), + ( + "description", + "The default director is not proud of their work, we hope you are.", + ), + ("hide_input", False), + ( + "choices", + [ + "Peter Sellers", + "Allan Smithe", + "Ridley Scott", + "Victor Fleming", + "John Houston", + ], + ), + ("validation", "^[a-z][A-Z]+$"), + ("validation_flags", ["verbose", "ascii"]), + ( + "skip_if", + "{{cookiecutter.director_credit == False}}", + ), + ("type", "string"), + ] + ), + ], + ), + ] + ) + }, + ) + yield context_with_valid_extra_0 + yield context_with_valid_extra_1 + yield context_with_valid_extra_2 + yield context_with_valid_extra_2_B + yield context_with_valid_extra_3 + yield context_with_valid_extra_4 + yield context_with_valid_extra_5 + yield context_with_valid_extra_6 + yield context_with_valid_extra_7 + yield context_with_valid_extra_8 + + +def gen_context_data_inputs_expected_var(): + # Test the ability to change the variable's name field (since it is used + # to identify the variable to be modifed) with extra context and to remove + # a key from the context via the removal token: '<>' + context_with_valid_extra_2 = ( + { + 'context_file': 'tests/test-generate-context-v2/representative.json', + 'extra_context': [ + { + 'name': 'director_credit::producer_credit', + 'prompt': 'Is there a producer credit on this film?', + 'description': 'There are usually a lot of producers...', + }, + { + 'name': 'director_name', + 'skip_if': '<>', + }, + ], + }, + { + "representative": OrderedDict( + [ + ("name", "cc-representative"), + ("cookiecutter_version", "2.0.0"), + ( + "variables", + [ + OrderedDict( + [ + ("name", "producer_credit"), + ("default", True), + ( + "prompt", + "Is there a producer credit on this film?", + ), + ( + "description", + "There are usually a lot of producers...", + ), + ("type", "boolean"), + ] + ), + OrderedDict( + [ + ("name", "director_name"), + ("default", "Allan Smithe"), + ("prompt", "What's the Director's full name?"), + ("prompt_user", True), + ( + "description", + "The default director is not proud of their work, we hope you are.", + ), + ("hide_input", False), + ( + "choices", + [ + "Allan Smithe", + "Ridley Scott", + "Victor Fleming", + "John Houston", + ], + ), + ("validation", "^[a-z][A-Z]+$"), + ("validation_flags", ["verbose", "ascii"]), + ("type", "string"), + ] + ), + ], + ), + ] + ) + }, + ) + # Test the ability to change the variable's name field (since it is used + # to identify the variable to be modifed) with extra context and to also + # test that any other references in other variables that might use the + # original variable name get updated as well. + context_with_valid_extra_2_B = ( + { + 'context_file': 'tests/test-generate-context-v2/representative_2B.json', + 'extra_context': [ + { + 'name': 'director_credit::producer_credit', + 'prompt': 'Is there a producer credit on this film?', + 'description': 'There are usually a lot of producers...', + }, + ], + }, + { + "representative_2B": OrderedDict( + [ + ("name", "cc-representative"), + ("cookiecutter_version", "2.0.0"), + ( + "variables", + [ + OrderedDict( + [ + ("name", "producer_credit"), + ("default", True), + ( + "prompt", + "Is there a producer credit on this film?", + ), + ( + "description", + "There are usually a lot of producers...", + ), + ("type", "boolean"), + ] + ), + OrderedDict( + [ + ("name", "director_name"), + ("default", "Allan Smithe"), + ("prompt", "What's the Director's full name?"), + ("prompt_user", True), + ( + "description", + "The default director is not proud of their work, we hope you are.", + ), + ("hide_input", False), + ( + "choices", + [ + "Allan Smithe", + "Ridley Scott", + "Victor Fleming", + "John Houston", + "{{cookiecutter.producer_credit}}", + ], + ), + ("validation", "^[a-z][A-Z]+$"), + ("validation_flags", ["verbose", "ascii"]), + ( + "skip_if", + "{{cookiecutter.producer_credit == False}}", + ), + ("type", "string"), + ] + ), + ], + ), + ] + ) + }, + ) + + yield context_with_valid_extra_2 + yield context_with_valid_extra_2_B + + +@pytest.mark.usefixtures('clean_system') +@pytest.mark.parametrize( + 'input_params, expected_context', gen_context_data_inputs_expected() +) +def test_generate_context_with_extra_context_dictionary( + input_params, expected_context, monkeypatch +): + """ + Test the generated context with extra content overwrite to multiple fields, + with creation of new fields NOT allowed. + """ + assert generate.generate_context(**input_params) == expected_context + + +@pytest.mark.usefixtures('clean_system') +@pytest.mark.parametrize( + 'input_params, expected_context', gen_context_data_inputs_expected_var() +) +def test_generate_context_with_extra_context_dictionary_var( + input_params, expected_context, monkeypatch +): + """ + Test the generated context with extra content overwrite to multiple fields, + with creation of new fields NOT allowed. + """ + assert generate.generate_context(**input_params) == expected_context + + +def context_data_2(): + context_with_valid_extra_2_A = ( + { + 'context_file': 'tests/test-generate-context-v2/representative.json', + }, + { + "representative": OrderedDict( + [ + ("name", "cc-representative"), + ("cookiecutter_version", "2.0.0"), + ( + "variables", + [ + OrderedDict( + [ + ("name", "director_credit"), + ("default", True), + ( + "prompt", + "Is there a director credit on this film?", + ), + ( + "description", + "Directors take credit for most of their films, usually...", + ), + ("type", "boolean"), + ] + ), + OrderedDict( + [ + ("name", "director_name"), + ("default", "Allan Smithe"), + ("prompt", "What's the Director's full name?"), + ("prompt_user", True), + ( + "description", + "The default director is not proud of their work, we hope you are.", + ), + ("hide_input", False), + ( + "choices", + [ + "Allan Smithe", + "Ridley Scott", + "Victor Fleming", + "John Houston", + ], + ), + ("validation", "^[a-z][A-Z]+$"), + ("validation_flags", ["verbose", "ascii"]), + ("type", "string"), + ] + ), + ], + ), + ] + ) + }, + ) + + +@pytest.mark.usefixtures('clean_system') +def test_raise_exception_when_attempting_to_remove_mandatory_field(): + """ + Test that ValueError is raised if attempt is made to remove a mandatory + field -- the default field. + The other mandatory field, name, cannot be removed because it has to be + used to specify which variable to remove. + """ + xtra_context = [ + { + 'name': 'director_name', + 'default': '<>', + }, + ] + + with pytest.raises(ValueError) as excinfo: + generate.generate_context( + context_file='tests/test-generate-context-v2/representative.json', + default_context=None, + extra_context=xtra_context, + ) + + assert "Cannot remove mandatory 'default' field" in str(excinfo.value) diff --git a/tests/test_main.py b/tests/test_main.py index ee0f738f9..0af183596 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,4 +1,8 @@ """Collection of tests around cookiecutter's replay feature.""" +import collections +import os +import shutil + from cookiecutter.main import cookiecutter @@ -62,6 +66,57 @@ def test_replay_load_template_name( ) +def test_version_2_load_context_call(monkeypatch, mocker, user_config_file): + """Check that the version 2 load_context() is called. + + Change the current working directory temporarily to + 'tests/test-generate-context-v2/min-v2-cookiecutter' + for this test and call cookiecutter with '.' for the target template. + """ + monkeypatch.chdir('tests/test-generate-context-v2/min-v2-cookiecutter') + if os.path.exists('test-repo'): + shutil.rmtree('test-repo') + mock_replay_dump = mocker.patch('cookiecutter.main.dump') + + counts = {} + + def patch_load_context(counts): + counts['load_context'] = 0 + + def load_context(json_object, no_input=False, verbose=True, counts=counts): + counts["load_context"] += 1 + return collections.OrderedDict( + { + 'repo_name': 'test-repo', + } + ) + + return load_context + + def patch_prompt_for_config(counts): + counts['prompt_for_config'] = 0 + + def prompt_for_config(context, no_input=False): + counts["prompt_for_config"] += 1 + return {} + + mocker.patch('cookiecutter.main.prompt_for_config', patch_prompt_for_config(counts)) + mocker.patch('cookiecutter.main.load_context', patch_load_context(counts)) + + cookiecutter( + '.', + no_input=True, + replay=False, + config_file=user_config_file, + ) + + if os.path.exists('test-repo'): + shutil.rmtree('test-repo') + assert mock_replay_dump.call_count == 1 + assert counts["load_context"] == 1 + assert counts["prompt_for_config"] == 0 + + def test_custom_replay_file(monkeypatch, mocker, user_config_file): """Check that reply.load is called with the custom replay_file.""" monkeypatch.chdir('tests/fake-repo-tmpl')