From e986a347f70e2dca2de5e2a6503c43f8cd96cf5a Mon Sep 17 00:00:00 2001 From: eruber Date: Fri, 20 Oct 2017 20:56:57 -0700 Subject: [PATCH 01/51] hackebrot's proof-of-concept version of new cookiecutter context --- cookiecutter/context.py | 273 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 cookiecutter/context.py diff --git a/cookiecutter/context.py b/cookiecutter/context.py new file mode 100644 index 000000000..63a4805ec --- /dev/null +++ b/cookiecutter/context.py @@ -0,0 +1,273 @@ +# -*- coding: utf-8 -*- + +import codecs +import collections +import json +import pprint + +import click +from jinja2 import Environment + +DEFAULT_PROMPT = 'Please enter a value for "{variable.name}"' + +VALID_TYPES = [ + 'boolean', + 'yes_no', + 'int', + 'json', + 'string', +] + + +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_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 json.decoder.JSONDecodeError: + # Leave it up to click to ask the user 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' + + return click.prompt( + variable.prompt, + default=default_display, + hide_input=variable.hide_input, + type=click.BOOL, + ) + + +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, + '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_json(value): + return value + + +DESERIALIZERS = { + 'string': deserialize_string, + 'boolean': deserialize_boolean, + 'int': deserialize_int, + 'json': deserialize_json, + 'yes_no': deserialize_yes_no, +} + + +class Variable(object): + def __init__(self, name, default, **info): + + # mandatory fields + self.name = name + self.default = default + + # optional fields + self.description = info.get('description', None) + self.prompt = info.get('prompt', DEFAULT_PROMPT.format(variable=self)) + self.hide_input = info.get('hide_input', False) + + self.var_type = info.get('type', 'string') + if self.var_type not in VALID_TYPES: + msg = 'Invalid type {var_type} for variable' + raise ValueError(msg.format(var_type=self.var_type)) + + self.skip_if = info.get('skip_if', '') + if not isinstance(self.skip_if, str): + # skip_if was specified in cookiecutter.json + msg = 'Field skip_if is required to be a str, got {value}' + raise ValueError(msg.format(value=self.skip_if)) + + self.prompt_user = info.get('prompt_user', True) + if not isinstance(self.prompt_user, bool): + # prompt_user was specified in cookiecutter.json + msg = 'Field prompt_user is required to be a bool, got {value}' + raise ValueError(msg.format(value=self.prompt_user)) + + # choices are somewhat special as they can of every type + self.choices = info.get('choices', []) + if self.choices and default not in self.choices: + msg = 'Invalid default value {default} for choice variable' + raise ValueError(msg.format(default=self.default)) + + def __repr__(self): + return "<{class_name} {variable_name}>".format( + class_name=self.__class__.__name__, + variable_name=self.name, + ) + + +class CookiecutterTemplate(object): + def __init__(self, name, cookiecutter_version, variables, **info): + # 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, verbose): + env = Environment(extensions=['jinja2_time.TimeExtension']) + context = collections.OrderedDict({}) + + for variable in CookiecutterTemplate(**json_object): + if variable.skip_if: + skip_template = env.from_string(variable.skip_if) + if skip_template.render(cookiecutter=context) == 'True': + continue + + default = variable.default + + if isinstance(default, str): + template = env.from_string(default) + default = template.render(cookiecutter=context) + + deserialize = DESERIALIZERS[variable.var_type] + + if not variable.prompt_user: + context[variable.name] = deserialize(default) + continue + + if variable.choices: + prompt = prompt_choice + else: + prompt = PROMPTS[variable.var_type] + + if verbose and variable.description: + click.echo(variable.description) + + value = prompt(variable, default) + + if verbose: + width, _ = click.get_terminal_size() + click.echo('-' * width) + + context[variable.name] = deserialize(value) + + return context + + +def main(file_path): + """Load the json object and prompt the user for input""" + + with codecs.open(file_path, 'r', encoding='utf8') as f: + json_object = json.load(f, object_pairs_hook=collections.OrderedDict) + + pprint.pprint(load_context(json_object, True)) + + +if __name__ == '__main__': + main('cookiecutter.json') From a444833d4ba284094d7f649d054ea1bceccfaa2d Mon Sep 17 00:00:00 2001 From: eruber Date: Sat, 28 Oct 2017 00:51:57 -0700 Subject: [PATCH 02/51] Update version to 2.0.0 alpha --- cookiecutter/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cookiecutter/__init__.py b/cookiecutter/__init__.py index ee27f6d91..5ada8ad8c 100644 --- a/cookiecutter/__init__.py +++ b/cookiecutter/__init__.py @@ -7,4 +7,4 @@ Main package for Cookiecutter. """ -__version__ = '1.6.0' +__version__ = '2.0.0-alpha.u+1.6.0' From a67111ea669d1b50c10d5dfe8d72673e9e680f99 Mon Sep 17 00:00:00 2001 From: eruber Date: Sat, 28 Oct 2017 00:55:00 -0700 Subject: [PATCH 03/51] Call context v1 or v2 processing depending on which version is detected. --- cookiecutter/main.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cookiecutter/main.py b/cookiecutter/main.py index cc2515d9d..6b3db60d7 100644 --- a/cookiecutter/main.py +++ b/cookiecutter/main.py @@ -19,6 +19,8 @@ from .repository import determine_repo_dir from .utils import rmtree +from .context import context_is_version_2, load_context + logger = logging.getLogger(__name__) @@ -79,7 +81,12 @@ def cookiecutter( # prompt the user to manually configure at the command line. # except when 'no-input' flag is set - 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: + context['cookiecutter'] = prompt_for_config(context, no_input) # include template dir or url in the context dict context['cookiecutter']['_template'] = template From 295f39a42a4c72e7328b648f3e667c4afd311928 Mon Sep 17 00:00:00 2001 From: eruber Date: Sun, 29 Oct 2017 09:19:26 -0700 Subject: [PATCH 04/51] v2 context a speced by hackebrot in cookiecutter pull request 848 --- cookiecutter/context.py | 294 +++++++++++++++++++++++++++++++++++----- 1 file changed, 259 insertions(+), 35 deletions(-) diff --git a/cookiecutter/context.py b/cookiecutter/context.py index 63a4805ec..3cc984041 100644 --- a/cookiecutter/context.py +++ b/cookiecutter/context.py @@ -1,23 +1,74 @@ # -*- coding: utf-8 -*- -import codecs +""" +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 + +""" + +import logging import collections import json -import pprint +import re 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( @@ -46,6 +97,24 @@ def prompt_int(variable, default): ) +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' @@ -58,7 +127,10 @@ def process_json(user_value): object_pairs_hook=collections.OrderedDict, ) except json.decoder.JSONDecodeError: - # Leave it up to click to ask the user again + # 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( @@ -117,6 +189,8 @@ def prompt_choice(variable, default): 'string': prompt_string, 'boolean': prompt_boolean, 'int': prompt_int, + 'float': prompt_float, + 'uuid': prompt_uuid, 'json': prompt_json, 'yes_no': prompt_yes_no, } @@ -138,6 +212,14 @@ 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 @@ -146,45 +228,122 @@ def deserialize_json(value): '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. + - `choices` -- A list of choices, may be of mixed types. + - `validation` -- A string defining a regex to use to validation + user input. Defaults to None. + - `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.description = info.get('description', None) - self.prompt = info.get('prompt', DEFAULT_PROMPT.format(variable=self)) - self.hide_input = info.get('hide_input', False) + self.info = info + + self.description = self.check_type('description', None, str) + + self.prompt = self.check_type('prompt', DEFAULT_PROMPT.format(variable=self), str) + + self.hide_input = self.check_type('hide_input', False, bool) self.var_type = info.get('type', 'string') if self.var_type not in VALID_TYPES: - msg = 'Invalid type {var_type} for variable' - raise ValueError(msg.format(var_type=self.var_type)) - - self.skip_if = info.get('skip_if', '') - if not isinstance(self.skip_if, str): - # skip_if was specified in cookiecutter.json - msg = 'Field skip_if is required to be a str, got {value}' - raise ValueError(msg.format(value=self.skip_if)) - - self.prompt_user = info.get('prompt_user', True) - if not isinstance(self.prompt_user, bool): - # prompt_user was specified in cookiecutter.json - msg = 'Field prompt_user is required to be a bool, got {value}' - raise ValueError(msg.format(value=self.prompt_user)) - - # choices are somewhat special as they can of every type - self.choices = info.get('choices', []) + 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)) + + self.skip_if = self.check_type('skip_if', '', str) + + self.prompt_user = self.check_type('prompt_user', True, bool) + + # 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 = 'Invalid default value {default} for choice variable' - raise ValueError(msg.format(default=self.default)) + 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)) + + self.validation = self.check_type('validation', 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)) def __repr__(self): return "<{class_name} {variable_name}>".format( @@ -192,9 +351,53 @@ def __repr__(self): 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: The cookiecutter template name + :param cookiecutter_version: 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 human readable description of the 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 @@ -219,7 +422,16 @@ def __iter__(self): yield v -def load_context(json_object, verbose): +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']) context = collections.OrderedDict({}) @@ -237,7 +449,7 @@ def load_context(json_object, verbose): deserialize = DESERIALIZERS[variable.var_type] - if not variable.prompt_user: + if no_input or (not variable.prompt_user): context[variable.name] = deserialize(default) continue @@ -249,7 +461,17 @@ def load_context(json_object, verbose): if verbose and variable.description: click.echo(variable.description) - value = prompt(variable, default) + 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) + else: + # no validation defined + break if verbose: width, _ = click.get_terminal_size() @@ -260,14 +482,16 @@ def load_context(json_object, verbose): return context -def main(file_path): - """Load the json object and prompt the user for input""" +# def main(file_path): +# """Load the json object and prompt the user for input""" - with codecs.open(file_path, 'r', encoding='utf8') as f: - json_object = json.load(f, object_pairs_hook=collections.OrderedDict) +# import codecs +# import pprint +# with codecs.open(file_path, 'r', encoding='utf8') as f: +# json_object = json.load(f, object_pairs_hook=collections.OrderedDict) - pprint.pprint(load_context(json_object, True)) +# pprint.pprint(load_context(json_object, True, False)) -if __name__ == '__main__': - main('cookiecutter.json') +# if __name__ == '__main__': +# main('cookiecutter.json') From 82eebbe4c4b497e60e423638bf28ab769af32f24 Mon Sep 17 00:00:00 2001 From: eruber Date: Sun, 29 Oct 2017 09:21:16 -0700 Subject: [PATCH 05/51] Added detection of v1 or v2 context & appropriate call to v2 overwrite --- cookiecutter/generate.py | 52 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/cookiecutter/generate.py b/cookiecutter/generate.py index 3bdd1e029..b098d8601 100644 --- a/cookiecutter/generate.py +++ b/cookiecutter/generate.py @@ -27,6 +27,8 @@ from .hooks import run_hook from .utils import make_sure_path_exists, work_in, rmtree +from .context import context_is_version_2 + logger = logging.getLogger(__name__) @@ -72,6 +74,38 @@ def apply_overwrites_to_context(context, overwrite_context): context[variable] = overwrite +def apply_overwrites_to_context2(context, overwrite_context): + """Modify the given version 2 context in place based on the overwrite_context.""" + + # Need a more pythonic way of doing this, its convoluted... + variable_idx_by_name = {} + for idx, dictionary in enumerate(context['variables']): + variable_idx_by_name[dictionary['name']] = idx + + for variable, overwrite in overwrite_context.items(): + if variable not in variable_idx_by_name.keys(): + # Do not include variables which are not used in the template + continue + + if 'choices' in context['variables'][variable_idx_by_name[variable]].keys(): + context_value = context['variables'][variable_idx_by_name[variable]]['choices'] + else: + context_value = context['variables'][variable_idx_by_name[variable]]['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 first item in list) + # see ``cookiecutter.prompt.prompt_choice_for_config`` + context_value.remove(overwrite) + context_value.insert(0, overwrite) + context['variables'][variable_idx_by_name[variable]]['default'] = overwrite + else: + # Simply overwrite the value for this variable + context['variables'][variable_idx_by_name[variable]]['default'] = overwrite + + def generate_context(context_file='cookiecutter.json', default_context=None, extra_context=None): """Generate the context for a Cookiecutter project template. @@ -105,10 +139,20 @@ def generate_context(context_file='cookiecutter.json', default_context=None, # Overwrite context variable defaults with the default context from the # user's global config, if available - if default_context: - apply_overwrites_to_context(obj, default_context) - 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: + apply_overwrites_to_context2(obj, default_context) + if extra_context: + apply_overwrites_to_context2(obj, extra_context) + else: + logger.debug("Context is version 1") + + if default_context: + apply_overwrites_to_context(obj, default_context) + if extra_context: + apply_overwrites_to_context(obj, extra_context) logger.debug('Context generated is {}'.format(context)) return context From b60f1c66da0b6bf22b57424a2c66fb1016f6ae3e Mon Sep 17 00:00:00 2001 From: eruber Date: Sun, 29 Oct 2017 09:22:29 -0700 Subject: [PATCH 06/51] Added cov-report term-missing to pytext addopts in order to display missing coverage lines column --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 83901b4e9..805cc520d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -16,5 +16,5 @@ universal = 1 [tool:pytest] testpaths = tests -addopts = --cov=cookiecutter +addopts = --cov-report term-missing --cov=cookiecutter From aaf1a65c6a1d2702ef96725ba864d3b79c260777 Mon Sep 17 00:00:00 2001 From: eruber Date: Sun, 29 Oct 2017 09:23:37 -0700 Subject: [PATCH 07/51] Added test for v2 load_context call --- tests/test_main.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/test_main.py b/tests/test_main.py index 1198fb037..d3e326efa 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -56,3 +56,32 @@ def test_replay_load_template_name( user_config_data['replay_dir'], 'fake-repo-tmpl', ) + + +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') + + mock_replay_dump = mocker.patch('cookiecutter.main.dump') + + mock_version_1_prompt_for_config = mocker.patch('cookiecutter.main.prompt_for_config') + mock_version_2_load_context = mocker.patch('cookiecutter.main.load_context') + + mocker.patch('cookiecutter.main.generate_files') + + cookiecutter( + '.', + no_input=True, + replay=False, + config_file=user_config_file, + ) + + assert mock_version_1_prompt_for_config.call_count == 0 + assert mock_version_2_load_context.call_count == 1 + assert mock_replay_dump.call_count == 1 From 25ca798d0681402ac1c9f05fcfdd9dc9940713d8 Mon Sep 17 00:00:00 2001 From: eruber Date: Sun, 29 Oct 2017 09:26:05 -0700 Subject: [PATCH 08/51] Added tests & test support files for testing context v2 generation in generate.py --- .../min-v2-cookiecutter/cookiecutter.json | 5 + tests/test-generate-context-v2/test.json | 20 ++ .../test_choices.json | 17 ++ tests/test_generate_context_v2.py | 189 ++++++++++++++++++ 4 files changed, 231 insertions(+) create mode 100644 tests/test-generate-context-v2/min-v2-cookiecutter/cookiecutter.json create mode 100644 tests/test-generate-context-v2/test.json create mode 100644 tests/test-generate-context-v2/test_choices.json create mode 100644 tests/test_generate_context_v2.py 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/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.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_generate_context_v2.py b/tests/test_generate_context_v2.py new file mode 100644 index 000000000..0fc654c45 --- /dev/null +++ b/tests/test_generate_context_v2.py @@ -0,0 +1,189 @@ +# -*- coding: utf-8 -*- + +""" +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'} + }, + { + "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_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_default_not_in_choices + + +@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. + """ + assert generate.generate_context(**input_params) == expected_context From 3210c66bb8ea966daf951cf17593fcf82431a83a Mon Sep 17 00:00:00 2001 From: eruber Date: Sun, 29 Oct 2017 09:27:22 -0700 Subject: [PATCH 09/51] Added tests & test support files for testing v2 context processing in the new context.py --- tests/test-context/cookiecutter.json | 154 +++++ tests/test-context/cookiecutter_choices.json | 36 ++ .../cookiecutter_val_failure.json | 21 + .../cookiecutter_val_success.json | 21 + tests/test_context.py | 605 ++++++++++++++++++ 5 files changed, 837 insertions(+) create mode 100644 tests/test-context/cookiecutter.json create mode 100644 tests/test-context/cookiecutter_choices.json create mode 100644 tests/test-context/cookiecutter_val_failure.json create mode 100644 tests/test-context/cookiecutter_val_success.json create mode 100644 tests/test_context.py diff --git a/tests/test-context/cookiecutter.json b/tests/test-context/cookiecutter.json new file mode 100644 index 000000000..72f91ed22 --- /dev/null +++ b/tests/test-context/cookiecutter.json @@ -0,0 +1,154 @@ +{ + "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": "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_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_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_context.py b/tests/test_context.py new file mode 100644 index 000000000..71fb05608 --- /dev/null +++ b/tests/test_context.py @@ -0,0 +1,605 @@ +# -*- coding: utf-8 -*- + +""" +test_context +------------ + +Tests for `cookiecutter.context` module that handles prompts for v2 context. +""" +from __future__ import unicode_literals + +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'] == False + assert 'docs_tool' not in cc_cfg.keys() + assert cc_cfg['year'] == time.strftime('%Y') + assert cc_cfg['incept_year'] == 2017 + assert cc_cfg['released'] == 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)]))]) + + +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_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) + + assert str(v) == \ + (':\n' + "name='module_name',\n" + "default='{{cookiecutter.plugin_name|lower|replace('-','_')}}',\n" + "description='None',\n" + "prompt='Please enter a name for your base python module',\n" + "hide_input='True',\n" + "var_type='string',\n" + "skip_if='',\n" + "prompt_user='True',\n" + "choices='[]',\n" + "validation='^[a-z_]+$',\n" + "validation_flag_names='['ignorecase']',\n" + "validation_flags='2',\n" + "validate='re.compile('^[a-z_]+$', re.IGNORECASE)'") + + +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_chioces(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_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 From 52e3da485ea09b838a735fb12e3c19e6d1935083 Mon Sep 17 00:00:00 2001 From: eruber Date: Mon, 30 Oct 2017 09:47:46 -0700 Subject: [PATCH 10/51] removed commented out lines at bottom of file that were a main entry point --- cookiecutter/context.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/cookiecutter/context.py b/cookiecutter/context.py index 3cc984041..6a2a1deda 100644 --- a/cookiecutter/context.py +++ b/cookiecutter/context.py @@ -480,18 +480,3 @@ def load_context(json_object, no_input=False, verbose=True): context[variable.name] = deserialize(value) return context - - -# def main(file_path): -# """Load the json object and prompt the user for input""" - -# import codecs -# import pprint -# with codecs.open(file_path, 'r', encoding='utf8') as f: -# json_object = json.load(f, object_pairs_hook=collections.OrderedDict) - -# pprint.pprint(load_context(json_object, True, False)) - - -# if __name__ == '__main__': -# main('cookiecutter.json') From a8ab28452a7dce4e7a1d9d535eab12792c6a34de Mon Sep 17 00:00:00 2001 From: eruber Date: Mon, 30 Oct 2017 16:53:34 -0700 Subject: [PATCH 11/51] Last of overwrite context tests dealing with default & choices entanglement --- cookiecutter/generate.py | 131 ++++- .../representative.json | 26 + tests/test_generate_context_v2.py | 486 +++++++++++++++++- 3 files changed, 614 insertions(+), 29 deletions(-) create mode 100644 tests/test-generate-context-v2/representative.json diff --git a/cookiecutter/generate.py b/cookiecutter/generate.py index b098d8601..d6968bf91 100644 --- a/cookiecutter/generate.py +++ b/cookiecutter/generate.py @@ -74,36 +74,111 @@ def apply_overwrites_to_context(context, overwrite_context): context[variable] = overwrite -def apply_overwrites_to_context2(context, overwrite_context): - """Modify the given version 2 context in place based on the overwrite_context.""" +def apply_default_overwrites_to_context2(context, overwrite_default_context): + """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) + 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 first 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 apply_extra_overwrites_to_context2(context, extra_context): + """ + Modify the given version 2 context in place based on extra_context. - # Need a more pythonic way of doing this, its convoluted... - variable_idx_by_name = {} - for idx, dictionary in enumerate(context['variables']): - variable_idx_by_name[dictionary['name']] = idx + The extra_context parameter may be a dictionary or a list of dictionaries. - for variable, overwrite in overwrite_context.items(): - if variable not in variable_idx_by_name.keys(): - # Do not include variables which are not used in the template - continue + 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. - if 'choices' in context['variables'][variable_idx_by_name[variable]].keys(): - context_value = context['variables'][variable_idx_by_name[variable]]['choices'] - else: - context_value = context['variables'][variable_idx_by_name[variable]]['default'] + 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 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 first item in list) - # see ``cookiecutter.prompt.prompt_choice_for_config`` - context_value.remove(overwrite) - context_value.insert(0, overwrite) - context['variables'][variable_idx_by_name[variable]]['default'] = overwrite - else: - # Simply overwrite the value for this variable - context['variables'][variable_idx_by_name[variable]]['default'] = overwrite + 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. + + """ + if isinstance(extra_context, dict): + apply_default_overwrites_to_context2(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) + if var_dict: + # Since creation of new key/value pairs is NOT desired, we only use a key + # that is common to both variables context and the extra context + common_keys = [key for key in xtra_ctx_item.keys() if key in var_dict.keys()] + for key in common_keys: + if xtra_ctx_item[key] == '<>': + var_dict.pop(key, None) + else: + # normal field update + var_dict[key] = xtra_ctx_item[key] + + # After all fields have been update, 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()): + # 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']) + + var_dict['choices'].insert(0, var_dict['default']) + + if ('default' not in common_keys) & ('choices' in common_keys): + # choices updated, so update default based on first location in choices + var_dict['default'] = var_dict['choices'][0] + + if replace_name: + var_dict['name'] = replace_name + else: + msg = "No variable found in context whose name matches extra context name '{name}'" + raise ValueError(msg.format(name=xtra_ctx_name)) + else: + msg = "Extra context dictionary item {item} is missing a 'name' key." + raise ValueError(msg.format(item=xtra_ctx_item)) + else: + msg = "Extra context list item '{item}' is of type {t}, should be a dictionary." + raise ValueError(msg.format(item=str(xtra_ctx_item), t=type(xtra_ctx_item).__name__)) + 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, @@ -143,9 +218,9 @@ def generate_context(context_file='cookiecutter.json', default_context=None, logger.debug("Context is version 2") if default_context: - apply_overwrites_to_context2(obj, default_context) + apply_default_overwrites_to_context2(obj, default_context) if extra_context: - apply_overwrites_to_context2(obj, extra_context) + apply_extra_overwrites_to_context2(obj, extra_context) else: logger.debug("Context is version 1") 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.py b/tests/test_generate_context_v2.py index 0fc654c45..9d6dc09c9 100644 --- a/tests/test_generate_context_v2.py +++ b/tests/test_generate_context_v2.py @@ -46,7 +46,7 @@ def context_data(): context_with_default = ( { 'context_file': 'tests/test-generate-context-v2/test.json', - 'default_context': {'full_name': 'James Patrick Morgan'} + 'default_context': {'full_name': 'James Patrick Morgan', 'this_key_ignored': 'not_in_context'} }, { "test": OrderedDict([ @@ -187,3 +187,487 @@ def test_generate_context(input_params, expected_context): according expected context. """ assert generate.generate_context(**input_params) == expected_context + + +@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 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 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.director_credit == 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_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 + +@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 From cb459d26fda397ac6d01d483896ae2a3c74d589e Mon Sep 17 00:00:00 2001 From: eruber Date: Mon, 30 Oct 2017 17:56:41 -0700 Subject: [PATCH 12/51] Added context to the api list --- docs/cookiecutter.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/cookiecutter.rst b/docs/cookiecutter.rst index 9d39673db..f33fab44f 100644 --- a/docs/cookiecutter.rst +++ b/docs/cookiecutter.rst @@ -20,6 +20,14 @@ cookiecutter.config module :undoc-members: :show-inheritance: +cookiecutter.context module +--------------------------- + +.. automodule:: cookiecutter.context + :members: + :undoc-members: + :show-inheritance: + cookiecutter.environment module ------------------------------- From 8cda801caa44cb8d187ba42a40b5f136e77d64fa Mon Sep 17 00:00:00 2001 From: eruber Date: Mon, 30 Oct 2017 20:38:12 -0700 Subject: [PATCH 13/51] Fixed flake8 E501 --- tests/test_main.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_main.py b/tests/test_main.py index d3e326efa..5c78798a1 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -70,8 +70,10 @@ def test_version_2_load_context_call( mock_replay_dump = mocker.patch('cookiecutter.main.dump') - mock_version_1_prompt_for_config = mocker.patch('cookiecutter.main.prompt_for_config') - mock_version_2_load_context = mocker.patch('cookiecutter.main.load_context') + mock_version_1_prompt_for_config = mocker.patch( + 'cookiecutter.main.prompt_for_config') + mock_version_2_load_context = mocker.patch( + 'cookiecutter.main.load_context') mocker.patch('cookiecutter.main.generate_files') From b320a99e0f1e2621c661d055235c55c989b64105 Mon Sep 17 00:00:00 2001 From: eruber Date: Mon, 30 Oct 2017 20:39:20 -0700 Subject: [PATCH 14/51] temporarily attempt to suppress E501 --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index a34bc3c8a..6e8e0e8d8 100644 --- a/tox.ini +++ b/tox.ini @@ -7,6 +7,7 @@ commands = pytest --cov=cookiecutter {posargs:tests} deps = -rtest_requirements.txt [testenv:flake8] +ignore = E501 deps = flake8==2.3.0 pep8==1.6.2 From 117bf2045824334de0add41556beb59a4f0192f5 Mon Sep 17 00:00:00 2001 From: eruber Date: Mon, 30 Oct 2017 20:41:57 -0700 Subject: [PATCH 15/51] yea, that did not work removing ignore E501 --- tox.ini | 1 - 1 file changed, 1 deletion(-) diff --git a/tox.ini b/tox.ini index 6e8e0e8d8..a34bc3c8a 100644 --- a/tox.ini +++ b/tox.ini @@ -7,7 +7,6 @@ commands = pytest --cov=cookiecutter {posargs:tests} deps = -rtest_requirements.txt [testenv:flake8] -ignore = E501 deps = flake8==2.3.0 pep8==1.6.2 From 532c0fd322d8142dc178ed7e1eb3947f7175e238 Mon Sep 17 00:00:00 2001 From: eruber Date: Mon, 30 Oct 2017 21:27:38 -0700 Subject: [PATCH 16/51] Fixed tox py3.5 error --- tests/test_context.py | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/tests/test_context.py b/tests/test_context.py index 71fb05608..5d9030bb7 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -498,21 +498,20 @@ def test_variable_str(): validation_flags=['ignorecase'], hide_input=True) - assert str(v) == \ - (':\n' - "name='module_name',\n" - "default='{{cookiecutter.plugin_name|lower|replace('-','_')}}',\n" - "description='None',\n" - "prompt='Please enter a name for your base python module',\n" - "hide_input='True',\n" - "var_type='string',\n" - "skip_if='',\n" - "prompt_user='True',\n" - "choices='[]',\n" - "validation='^[a-z_]+$',\n" - "validation_flag_names='['ignorecase']',\n" - "validation_flags='2',\n" - "validate='re.compile('^[a-z_]+$', re.IGNORECASE)'") + 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) + assert "validate='re.compile('^[a-z_]+$', re.IGNORECASE)'" in str(v) def test_variable_option_raise_invalid_type_value_error(): From 71182626f4c912a5f67f47f17c12e203368f48ca Mon Sep 17 00:00:00 2001 From: eruber Date: Mon, 30 Oct 2017 21:28:05 -0700 Subject: [PATCH 17/51] More Flake8 nonsense --- tests/test_main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_main.py b/tests/test_main.py index 5c78798a1..38e5dab61 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -71,9 +71,9 @@ def test_version_2_load_context_call( mock_replay_dump = mocker.patch('cookiecutter.main.dump') mock_version_1_prompt_for_config = mocker.patch( - 'cookiecutter.main.prompt_for_config') + 'cookiecutter.main.prompt_for_config') mock_version_2_load_context = mocker.patch( - 'cookiecutter.main.load_context') + 'cookiecutter.main.load_context') mocker.patch('cookiecutter.main.generate_files') From 21a37d37b1cc4f661fb6bb2bb2941aa1e31ae423 Mon Sep 17 00:00:00 2001 From: eruber Date: Mon, 30 Oct 2017 21:51:00 -0700 Subject: [PATCH 18/51] tox py3.4 error fixed, json.decoder.JSONDecodeError exception is ValueError in py3.4 --- cookiecutter/context.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cookiecutter/context.py b/cookiecutter/context.py index 6a2a1deda..4f6ac72c1 100644 --- a/cookiecutter/context.py +++ b/cookiecutter/context.py @@ -126,7 +126,11 @@ def process_json(user_value): user_value, object_pairs_hook=collections.OrderedDict, ) + except ValueError: + # ValueError raised in Python 3.4 + raise click.UsageError('Unable to decode to JSON.') except json.decoder.JSONDecodeError: + # json.decoder.JSONDecodeError raised in Python 3.5, 3.6 # 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 From 3b995007d73209fc2d30a87aeb34decca28efa90 Mon Sep 17 00:00:00 2001 From: eruber Date: Mon, 30 Oct 2017 21:56:19 -0700 Subject: [PATCH 19/51] tox py3.4 json.decoder.JSONDecodeError inherits from ValueError so we use it for portability --- cookiecutter/context.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cookiecutter/context.py b/cookiecutter/context.py index 4f6ac72c1..a3390df63 100644 --- a/cookiecutter/context.py +++ b/cookiecutter/context.py @@ -127,10 +127,9 @@ def process_json(user_value): object_pairs_hook=collections.OrderedDict, ) except ValueError: - # ValueError raised in Python 3.4 - raise click.UsageError('Unable to decode to JSON.') - except json.decoder.JSONDecodeError: # 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 From 46415e1d01bc3fc412c639dd8fcb58f538831879 Mon Sep 17 00:00:00 2001 From: eruber Date: Mon, 30 Oct 2017 22:16:04 -0700 Subject: [PATCH 20/51] Fixed py3.3 error --- tests/test_context.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_context.py b/tests/test_context.py index 5d9030bb7..463248c0a 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -8,6 +8,7 @@ """ from __future__ import unicode_literals +import sys import os.path import time import json @@ -511,7 +512,11 @@ def test_variable_str(): assert "validation='^[a-z_]+$'" in str(v) assert "validation_flag_names='['ignorecase']'" in str(v) assert "validation_flags='2'" in str(v) - assert "validate='re.compile('^[a-z_]+$', 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(): From 765f918a40d70914b74f3d07280df16dcee13545 Mon Sep 17 00:00:00 2001 From: eruber Date: Mon, 30 Oct 2017 23:56:24 -0700 Subject: [PATCH 21/51] edits for line length flake8 E501 --- cookiecutter/context.py | 14 ++++++++++---- tests/test_generate_context_v2.py | 7 +++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/cookiecutter/context.py b/cookiecutter/context.py index a3390df63..3671adbcd 100644 --- a/cookiecutter/context.py +++ b/cookiecutter/context.py @@ -64,7 +64,8 @@ def context_is_version_2(cookiecutter_context): # 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: + if (cookiecutter_context.keys() & + SET_OF_REQUIRED_FIELDS) == SET_OF_REQUIRED_FIELDS: return True else: return False @@ -246,8 +247,10 @@ class Variable(object): 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 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: @@ -303,7 +306,10 @@ def __init__(self, name, default, **info): self.description = self.check_type('description', None, str) - self.prompt = self.check_type('prompt', DEFAULT_PROMPT.format(variable=self), str) + self.prompt = self.check_type( + 'prompt', + DEFAULT_PROMPT.format(variable=self), + str) self.hide_input = self.check_type('hide_input', False, bool) diff --git a/tests/test_generate_context_v2.py b/tests/test_generate_context_v2.py index 9d6dc09c9..11c44f0c0 100644 --- a/tests/test_generate_context_v2.py +++ b/tests/test_generate_context_v2.py @@ -663,9 +663,12 @@ def gen_context_data_inputs_expected(): yield context_with_valid_extra_7 yield context_with_valid_extra_8 + @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): +@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. From e86da0d63407fd89ae66fa1b01b24eb700d4380a Mon Sep 17 00:00:00 2001 From: eruber Date: Tue, 31 Oct 2017 00:16:19 -0700 Subject: [PATCH 22/51] More Flake8 E501 line length issues --- cookiecutter/context.py | 2 +- cookiecutter/generate.py | 105 +++++++++++++++++++++++++++++---------- tests/test_context.py | 2 +- 3 files changed, 82 insertions(+), 27 deletions(-) diff --git a/cookiecutter/context.py b/cookiecutter/context.py index 3671adbcd..b3230746a 100644 --- a/cookiecutter/context.py +++ b/cookiecutter/context.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- - +# flake8: noqa """ cookiecutter.context -------------------- diff --git a/cookiecutter/generate.py b/cookiecutter/generate.py index d6968bf91..bf0dfbd18 100644 --- a/cookiecutter/generate.py +++ b/cookiecutter/generate.py @@ -75,10 +75,13 @@ def apply_overwrites_to_context(context, overwrite_context): def apply_default_overwrites_to_context2(context, overwrite_default_context): - """Modify the given version 2 context in place based on the overwrite_default_context.""" + """ + 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) + 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'] @@ -89,7 +92,7 @@ def apply_default_overwrites_to_context2(context, overwrite_default_context): # We are dealing with a choice variable if overwrite in context_value: # This overwrite is actually valid for the given context - # Let's set it as default (by definition first item in list) + # 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) @@ -118,10 +121,58 @@ def apply_extra_overwrites_to_context2(context, extra_context): 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. + 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. Therefore the following + extra context dictionary entry snytax 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. """ if isinstance(extra_context, dict): @@ -130,19 +181,21 @@ def apply_extra_overwrites_to_context2(context, extra_context): 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_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) + 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 variables context and the extra context - common_keys = [key for key in xtra_ctx_item.keys() if key in var_dict.keys()] + # 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] == '<>': var_dict.pop(key, None) @@ -150,32 +203,34 @@ def apply_extra_overwrites_to_context2(context, extra_context): # normal field update var_dict[key] = xtra_ctx_item[key] - # After all fields have been update, there is some house-keeping to do. - # The default/choices house-keeping could effecively be no-ops if the + # 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()): + 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']) + 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): - # choices updated, so update default based on first location in choices + 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: var_dict['name'] = replace_name else: - msg = "No variable found in context whose name matches extra context name '{name}'" + 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." + 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." - raise ValueError(msg.format(item=str(xtra_ctx_item), t=type(xtra_ctx_item).__name__)) + 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 else: msg = "Extra context must be a dictionary or a list of dictionaries!" raise ValueError(msg) @@ -446,8 +501,8 @@ def generate_files(repo_dir, context=None, output_dir='.', ) 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/tests/test_context.py b/tests/test_context.py index 463248c0a..09382025f 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- - +# flake8: noqa """ test_context ------------ From 827c3af73c9671301d659bb995e68a1e30017a77 Mon Sep 17 00:00:00 2001 From: eruber Date: Tue, 31 Oct 2017 00:18:54 -0700 Subject: [PATCH 23/51] Flake8 E501 long line length issues --- cookiecutter/generate.py | 4 ++-- tests/test_generate_context_v2.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cookiecutter/generate.py b/cookiecutter/generate.py index bf0dfbd18..ea6c9292e 100644 --- a/cookiecutter/generate.py +++ b/cookiecutter/generate.py @@ -208,8 +208,8 @@ def apply_extra_overwrites_to_context2(context, extra_context): # 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 + # 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 diff --git a/tests/test_generate_context_v2.py b/tests/test_generate_context_v2.py index 11c44f0c0..1d211ea67 100644 --- a/tests/test_generate_context_v2.py +++ b/tests/test_generate_context_v2.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- - +# flake8: noqa """ test_generate_convext_v2 ------------------------ From 4e19fd217994593c7da0c2dda45e4f28905d963e Mon Sep 17 00:00:00 2001 From: eruber Date: Tue, 31 Oct 2017 08:32:16 -0700 Subject: [PATCH 24/51] Rename context v2 overwrite function to enhance clarification, mirrored v1 overwrite call structure. --- cookiecutter/generate.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cookiecutter/generate.py b/cookiecutter/generate.py index ea6c9292e..e5f64ad45 100644 --- a/cookiecutter/generate.py +++ b/cookiecutter/generate.py @@ -102,7 +102,7 @@ def apply_default_overwrites_to_context2(context, overwrite_default_context): var_dict['default'] = overwrite -def apply_extra_overwrites_to_context2(context, extra_context): +def apply_overwrites_to_context_v2(context, extra_context): """ Modify the given version 2 context in place based on extra_context. @@ -273,9 +273,9 @@ def generate_context(context_file='cookiecutter.json', default_context=None, logger.debug("Context is version 2") if default_context: - apply_default_overwrites_to_context2(obj, default_context) + apply_overwrites_to_context_v2(obj, default_context) if extra_context: - apply_extra_overwrites_to_context2(obj, extra_context) + apply_overwrites_to_context_v2(obj, extra_context) else: logger.debug("Context is version 1") From df6310c3a32b201410c72beb061b82633aeb3eac Mon Sep 17 00:00:00 2001 From: eruber Date: Tue, 31 Oct 2017 20:01:51 -0700 Subject: [PATCH 25/51] Force no user prompt for private variables (named with a leading underscore) --- cookiecutter/context.py | 27 +++++++++++++++++++-------- tests/test_context.py | 15 ++++++++++++++- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/cookiecutter/context.py b/cookiecutter/context.py index b3230746a..7b175b991 100644 --- a/cookiecutter/context.py +++ b/cookiecutter/context.py @@ -14,6 +14,7 @@ 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 """ @@ -304,15 +305,16 @@ def __init__(self, name, default, **info): # optional fields self.info = info + # -- DESCRIPTION ----------------------------------------------------- self.description = self.check_type('description', None, str) - self.prompt = self.check_type( - 'prompt', - DEFAULT_PROMPT.format(variable=self), - 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}' @@ -320,21 +322,29 @@ def __init__(self, name, default, **info): var_name=self.name, types=VALID_TYPES)) + # -- SKIP_IF --------------------------------------------------------- self.skip_if = self.check_type('skip_if', '', str) + # -- 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_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] @@ -353,6 +363,7 @@ def __init__(self, name, default, **info): 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( @@ -388,9 +399,9 @@ def __init__(self, name, cookiecutter_version, variables, **info): """ Mandatorty Parameters - :param name: The cookiecutter template name - :param cookiecutter_version: The version of the cookiecutter application - that is compatible with this template. + :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. @@ -398,7 +409,7 @@ def __init__(self, name, cookiecutter_version, variables, **info): Optional Parameters (via \**info) :param authors: An array of string - maintainers of the template. - :param description: A human readable description 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. diff --git a/tests/test_context.py b/tests/test_context.py index 09382025f..1ded038ab 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -474,6 +474,20 @@ def test_variable_validation_compile_exception(): 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 == False + + def test_variable_repr(): v = context.Variable( @@ -547,7 +561,6 @@ def test_cookiecutter_template_repr(): assert repr(cct) == "" -# ############################################################################ def test_load_context_with_input_chioces(mocker): cc = load_cookiecutter('tests/test-context/cookiecutter_choices.json') From a67ccdd361785ce35cd97741d4212232abcfdb9b Mon Sep 17 00:00:00 2001 From: eruber Date: Fri, 3 Nov 2017 21:06:05 -0700 Subject: [PATCH 26/51] clarified function name to be more obvious it was a version 2 call --- cookiecutter/generate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cookiecutter/generate.py b/cookiecutter/generate.py index e5f64ad45..d4a034efb 100644 --- a/cookiecutter/generate.py +++ b/cookiecutter/generate.py @@ -74,7 +74,7 @@ def apply_overwrites_to_context(context, overwrite_context): context[variable] = overwrite -def apply_default_overwrites_to_context2(context, overwrite_default_context): +def apply_default_overwrites_to_context_v2(context, overwrite_default_context): """ Modify the given version 2 context in place based on the overwrite_default_context. @@ -176,7 +176,7 @@ def apply_overwrites_to_context_v2(context, extra_context): """ if isinstance(extra_context, dict): - apply_default_overwrites_to_context2(context, extra_context) + 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): From 492293a97488c6ce1ce517bc2985abced430efeb Mon Sep 17 00:00:00 2001 From: eruber Date: Fri, 3 Nov 2017 22:50:39 -0700 Subject: [PATCH 27/51] version number edit --- cookiecutter/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cookiecutter/__init__.py b/cookiecutter/__init__.py index 5ada8ad8c..ab2a0f901 100644 --- a/cookiecutter/__init__.py +++ b/cookiecutter/__init__.py @@ -7,4 +7,4 @@ Main package for Cookiecutter. """ -__version__ = '2.0.0-alpha.u+1.6.0' +__version__ = '2.0.0-alpha+1.6.0' From 2984aa50e9a3ea8b2e6ebbc783a8204796cbc149 Mon Sep 17 00:00:00 2001 From: eruber Date: Sun, 5 Nov 2017 08:18:38 -0700 Subject: [PATCH 28/51] Over-written variable names need to be resolved if original name is used by other variable fields --- cookiecutter/generate.py | 33 ++++++++++++ .../representative_2B.json | 26 +++++++++ tests/test_generate_context_v2.py | 54 +++++++++++++++++-- 3 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 tests/test-generate-context-v2/representative_2B.json diff --git a/cookiecutter/generate.py b/cookiecutter/generate.py index d4a034efb..ccd2eaff3 100644 --- a/cookiecutter/generate.py +++ b/cookiecutter/generate.py @@ -102,6 +102,31 @@ def apply_default_overwrites_to_context_v2(context, overwrite_default_context): var_dict['default'] = overwrite +def resolve_changed_variable_names(context, variables_to_resolve): + """ + 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 an 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. @@ -175,6 +200,7 @@ def apply_overwrites_to_context_v2(context, extra_context): 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): @@ -221,6 +247,7 @@ def apply_overwrites_to_context_v2(context, extra_context): 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 @@ -231,6 +258,12 @@ def apply_overwrites_to_context_v2(context, extra_context): 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) 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.py b/tests/test_generate_context_v2.py index 1d211ea67..0e562e92d 100644 --- a/tests/test_generate_context_v2.py +++ b/tests/test_generate_context_v2.py @@ -354,9 +354,9 @@ def gen_context_data_inputs_expected(): } ) - # Test the ability to change the variable's name field since it is used to - # identify the variable to modifed with extra context and to remove a - # key from the context via the removal token: '<>' + # 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', @@ -401,6 +401,51 @@ def gen_context_data_inputs_expected(): ]) } ) + # 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 = ( @@ -438,7 +483,7 @@ def gen_context_data_inputs_expected(): ("choices", ["Allan Smithe", "Ridley Scott", "Victor Fleming", "John Houston"]), ("validation", "^[a-z][A-Z]+$"), ("validation_flags", ["verbose", "ascii"]), - ("skip_if", "{{cookiecutter.director_credit == False}}"), + ("skip_if", "{{cookiecutter.producer_credits == False}}"), ("type", "string") ]) ]) @@ -656,6 +701,7 @@ def gen_context_data_inputs_expected(): 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 From 4443f525a5cc56f10e2bf03bcd20271a8e88a68a Mon Sep 17 00:00:00 2001 From: eruber Date: Sat, 11 Nov 2017 12:45:26 -0700 Subject: [PATCH 29/51] Added do_if, if_no_skip_to, and if_yes_skip_to to load_context() with tests. --- cookiecutter/context.py | 94 +++++++++++++++----- tests/test-context/cookiecutter_skips_1.json | 88 ++++++++++++++++++ tests/test-context/cookiecutter_skips_2.json | 88 ++++++++++++++++++ tests/test-context/cookiecutter_skips_3.json | 69 ++++++++++++++ tests/test_context.py | 78 +++++++++++++++- 5 files changed, 390 insertions(+), 27 deletions(-) create mode 100644 tests/test-context/cookiecutter_skips_1.json create mode 100644 tests/test-context/cookiecutter_skips_2.json create mode 100644 tests/test-context/cookiecutter_skips_3.json diff --git a/cookiecutter/context.py b/cookiecutter/context.py index 7b175b991..941de036b 100644 --- a/cookiecutter/context.py +++ b/cookiecutter/context.py @@ -158,13 +158,28 @@ def prompt_yes_no(variable, default): else: default_display = 'n' - return click.prompt( + # 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""" @@ -325,6 +340,15 @@ def __init__(self, name, default, **info): # -- 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_NO_SKIP_TO --------------------------------------------------------- + self.if_no_skip_to = self.check_type('if_no_skip_to', None, str) + # -- PROMPT_USER ----------------------------------------------------- self.prompt_user = self.check_type('prompt_user', True, bool) # do not prompt for private variable names (beginning with _) @@ -455,12 +479,26 @@ def load_context(json_object, no_input=False, verbose=True): env = Environment(extensions=['jinja2_time.TimeExtension']) 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: + print("Skip variable: ", variable.name) + continue + if variable.skip_if: skip_template = env.from_string(variable.skip_if) if skip_template.render(cookiecutter=context) == '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): @@ -471,32 +509,42 @@ def load_context(json_object, no_input=False, verbose=True): if no_input or (not variable.prompt_user): context[variable.name] = deserialize(default) - continue - - if variable.choices: - prompt = prompt_choice else: - prompt = PROMPTS[variable.var_type] + 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) + else: + # no validation defined + break - if verbose and variable.description: - click.echo(variable.description) + if verbose: + width, _ = click.get_terminal_size() + click.echo('-' * width) - 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) - else: - # no validation defined - break + 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 + print("skip_to_variable_name: ", skip_to_variable_name) - if verbose: - width, _ = click.get_terminal_size() - click.echo('-' * width) + if variable.if_no_skip_to and context[variable.name] is False: + skip_to_variable_name = variable.if_no_skip_to + print("skip_to_variable_name: ", skip_to_variable_name) - context[variable.name] = deserialize(value) + 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/tests/test-context/cookiecutter_skips_1.json b/tests/test-context/cookiecutter_skips_1.json new file mode 100644 index 000000000..6e78c1c8a --- /dev/null +++ b/tests/test-context/cookiecutter_skips_1.json @@ -0,0 +1,88 @@ +{ + "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_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.py b/tests/test_context.py index 1ded038ab..68203c309 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -125,11 +125,11 @@ def test_load_context_defaults(): assert cc_cfg['plugin_name'] == 'emoji' assert cc_cfg['module_name'] == 'emoji' assert cc_cfg['license'] == 'MIT' - assert cc_cfg['docs'] == False - assert 'docs_tool' not in cc_cfg.keys() + 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'] == False + 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']" @@ -143,6 +143,76 @@ def test_load_context_defaults(): 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' @@ -485,7 +555,7 @@ def test_variable_forces_no_prompt_for_private_variable_names(): validation_flags=['ignorecase'], hide_input=True) - assert v.prompt_user == False + assert v.prompt_user is False def test_variable_repr(): From a6628e4058681ed4debb014ad5ed6228f14be67f Mon Sep 17 00:00:00 2001 From: eruber Date: Sat, 11 Nov 2017 12:52:18 -0700 Subject: [PATCH 30/51] Removed some debug code --- cookiecutter/context.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/cookiecutter/context.py b/cookiecutter/context.py index 941de036b..7c985fc04 100644 --- a/cookiecutter/context.py +++ b/cookiecutter/context.py @@ -486,7 +486,6 @@ def load_context(json_object, no_input=False, verbose=True): if variable.name == skip_to_variable_name: skip_to_variable_name = None else: - print("Skip variable: ", variable.name) continue if variable.skip_if: @@ -538,11 +537,9 @@ def load_context(json_object, no_input=False, verbose=True): if variable.if_yes_skip_to and context[variable.name] is True: skip_to_variable_name = variable.if_yes_skip_to - print("skip_to_variable_name: ", skip_to_variable_name) if variable.if_no_skip_to and context[variable.name] is False: skip_to_variable_name = variable.if_no_skip_to - print("skip_to_variable_name: ", skip_to_variable_name) if skip_to_variable_name: logger.warn("Processed all variables, but skip_to_variable_name '{}' was never found.".format(skip_to_variable_name)) From fa34583e2f63c3219d05168df229821afcc2ae27 Mon Sep 17 00:00:00 2001 From: eruber Date: Sat, 11 Nov 2017 13:04:23 -0700 Subject: [PATCH 31/51] Added comment regarding coverage.py issue #198 --- cookiecutter/context.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cookiecutter/context.py b/cookiecutter/context.py index 7c985fc04..69a8214fe 100644 --- a/cookiecutter/context.py +++ b/cookiecutter/context.py @@ -486,7 +486,11 @@ def load_context(json_object, no_input=False, verbose=True): if variable.name == skip_to_variable_name: skip_to_variable_name = None else: - continue + # 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) From 2f8e62d14fc40b89a20e5fa2e36536830f3de65d Mon Sep 17 00:00:00 2001 From: eruber Date: Tue, 14 Nov 2017 00:07:57 -0700 Subject: [PATCH 32/51] Added validation_msg & tests, plus check that if_yes_skip_to & if_no_skip_to are only specified for variables of type yes_no --- cookiecutter/context.py | 24 ++++++++- .../cookiecutter_val_failure_msg.json | 22 ++++++++ tests/test_context.py | 50 +++++++++++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 tests/test-context/cookiecutter_val_failure_msg.json diff --git a/cookiecutter/context.py b/cookiecutter/context.py index 69a8214fe..6818c673e 100644 --- a/cookiecutter/context.py +++ b/cookiecutter/context.py @@ -279,10 +279,20 @@ def __init__(self, name, default, **info): 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_flags` - A list of validation flag names that can be + - `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, @@ -345,9 +355,17 @@ def __init__(self, name, default, **info): # -- 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) @@ -365,6 +383,8 @@ def __init__(self, name, default, **info): # -- 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 @@ -529,6 +549,8 @@ def load_context(json_object, no_input=False, verbose=True): 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: # no validation defined break 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.py b/tests/test_context.py index 68203c309..28bc5f184 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -690,3 +690,53 @@ def test_load_context_with_input_with_validation_failure(mocker, capsys): 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) From 5f8e66b06816e76d902906478ed577991ee23481 Mon Sep 17 00:00:00 2001 From: eruber Date: Tue, 14 Nov 2017 00:08:51 -0700 Subject: [PATCH 33/51] Raise ValueError if attempt is made to remove a mandatory field from a variable --- cookiecutter/generate.py | 2 + tests/test_generate_context_v2.py | 62 ++++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/cookiecutter/generate.py b/cookiecutter/generate.py index ccd2eaff3..c18978fca 100644 --- a/cookiecutter/generate.py +++ b/cookiecutter/generate.py @@ -224,6 +224,8 @@ def apply_overwrites_to_context_v2(context, 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 diff --git a/tests/test_generate_context_v2.py b/tests/test_generate_context_v2.py index 0e562e92d..61088d51a 100644 --- a/tests/test_generate_context_v2.py +++ b/tests/test_generate_context_v2.py @@ -356,7 +356,7 @@ def gen_context_data_inputs_expected(): # 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: '<>' + # a key from the context via the removal token: '<>' context_with_valid_extra_2 = ( { 'context_file': 'tests/test-generate-context-v2/representative.json', @@ -720,3 +720,63 @@ def test_generate_context_with_extra_context_dictionary( 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) From c8e86c9a4f2efdf7239436f6440bc689e28dd875 Mon Sep 17 00:00:00 2001 From: Christo De Lange Date: Fri, 3 Feb 2023 11:26:48 -0500 Subject: [PATCH 34/51] Revert "get rid of nested .git" This reverts commit c83bffc02219f0d311570769f57bc9e8b5b4a63f. --- docs/HelloCookieCutter1/.gitignore | 47 ------ docs/HelloCookieCutter1/Readme.rst | 159 ------------------ docs/HelloCookieCutter1/cookiecutter.json | 5 - .../{{cookiecutter.file_name}}.py | 1 - 4 files changed, 212 deletions(-) delete mode 100644 docs/HelloCookieCutter1/.gitignore delete mode 100644 docs/HelloCookieCutter1/Readme.rst delete mode 100644 docs/HelloCookieCutter1/cookiecutter.json delete mode 100644 docs/HelloCookieCutter1/{{cookiecutter.directory_name}}/{{cookiecutter.file_name}}.py diff --git a/docs/HelloCookieCutter1/.gitignore b/docs/HelloCookieCutter1/.gitignore deleted file mode 100644 index cd2946ad7..000000000 --- a/docs/HelloCookieCutter1/.gitignore +++ /dev/null @@ -1,47 +0,0 @@ -# Windows image file caches -Thumbs.db -ehthumbs.db - -# Folder config file -Desktop.ini - -# Recycle Bin used on file shares -$RECYCLE.BIN/ - -# Windows Installer files -*.cab -*.msi -*.msm -*.msp - -# Windows shortcuts -*.lnk - -# ========================= -# Operating System Files -# ========================= - -# OSX -# ========================= - -.DS_Store -.AppleDouble -.LSOverride - -# Thumbnails -._* - -# Files that might appear in the root of a volume -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk diff --git a/docs/HelloCookieCutter1/Readme.rst b/docs/HelloCookieCutter1/Readme.rst deleted file mode 100644 index 4005a1bb2..000000000 --- a/docs/HelloCookieCutter1/Readme.rst +++ /dev/null @@ -1,159 +0,0 @@ -=========================================================== -Learn the Basics of Cookiecutter by Creating a Cookiecutter -=========================================================== - -The easiest way to understand what Cookiecutter does is to create a simple one -and see how it works. - -Cookiecutter takes a source directory tree and copies it into your new -project. It replaces all the names that it finds surrounded by *templating -tags* ``{{`` and ``}}`` with names that it finds in the file -``cookiecutter.json``. That's basically it. [1]_ - -The replaced names can be file names, directory names, and strings inside -files. - -With Cookiecutter, you can easily bootstrap a new project from a standard -form, which means you skip all the usual mistakes when starting a new -project. - -Before you can do anything in this example, you must have Python installed on -your machine. Go to the `Python Website `_ and follow -the instructions there. This includes the ``pip`` installer tool. Now run: - -.. code-block:: bash - - $ pip install cookiecutter - -Your First Cookiecutter ------------------------ - -To get started, create a directory somewhere on your computer. The name of -this directory will be the name of your Cookiecutter template, but it doesn't -constrain anything else---the generated project doesn't need to use the -template name, for example. Our project will be called ``HelloCookieCutter1``: - -.. code-block:: bash - - $ mkdir HelloCookieCutter1 - $ cd HelloCookieCutter1 - -Inside this directory, we create the directory tree to be copied into the -generated project. We want to generate a name for this directory, so we put -the directory name in templating tags ``{{`` and ``}}`` (yes, you type the -double-curly-braces onto the command line, just as you see them here): - -.. code-block:: bash - - $ mkdir {{cookiecutter.directory_name}} - $ cd {{cookiecutter.directory_name}} - -Anything inside templating tags can be placed inside a *namespace*. Here, by -putting ``directory_name`` inside the ``cookiecutter`` namespace, -``cookiecutter.directory_name`` will be looked up from the ``cookiecutter.json`` -file as the project is generated by Cookiecutter. - -Now we are inside the directory tree that will be copied. For the simplest -possible Cookiecutter template, we'll just include a single file. Again, we -want the file name to be looked up from ``cookiecutter.json``, so we name it -appropriately: - -.. code-block:: bash - - $ touch {{cookiecutter.file_name}}.py - -(``touch`` creates an empty file; you can just open it up in your editor). Now -edit the file so it contains: - -.. code-block:: python - - print("Hello, {{cookiecutter.greeting_recipient}}!") - -To finish, we create the ``cookiecutter.json`` file itself, so that -Cookiecutter can look up all our templated items. This file goes in our -``HelloCookieCutter1`` directory, and contains all the names we've used: - -.. code-block:: json - - { - "directory_name": "Hello", - "file_name": "Howdy", - "greeting_recipient": "Julie" - } - -Now we can actually run Cookiecutter and create a new project from our -template. Move to a directory where you want to create the new project. Then -run Cookiecutter and hand it the directory where the template lives. On my -(Windows, so the slashes go back instead of forward) machine, this happens to -be under the ``Git`` directory: - -.. code-block:: bash - - $ cookiecutter C:\Users\bruce\Documents\Git\HelloCookieCutter1 - directory_name [Hello]: - file_name [Howdy]: - greeting_recipient [Julie]: - -Cookiecutter tells us what the default name for each item is, and gives us the -option of replacing that name with something new. In this case, I just pressed -``Return`` for each one, to accept all the defaults. - -Now we have a generated directory called ``Hello``, containing a file -``Howdy.py``. When we run it: - -.. code-block:: bash - - $ cd Hello - $ python Howdy.py - Hello, Julie! - -Voila! Instant generated project! - -**Note**: The project we've created here happens to be Python, but -Cookiecutter is just replacing templated items with names it looks up in -``cookiecutter.json``, so you can produce projects of any kind, including -projects that aren't programs. - -This is nice, but what if you want to share your Cookiecutter template with -everyone on the Internet? The easiest way is to upload it to a version control -repository. As you might have guessed by the ``Git`` subdirectory, this -example is on GitHub. Conveniently, Cookiecutter can build a project directly -from an internet repository, like the one for this very example. For variety, -this time we'll replace the values from ``cookiecutter.json`` with our own: - -.. code-block:: bash - - $ cookiecutter https://github.com/BruceEckel/HelloCookieCutter1 - Cloning into 'HelloCookieCutter1'... - remote: Counting objects: 37, done. - Unpacking objects: 21% (8/37) - remote: Total 37 (delta 19), reused 21 (delta 3), pack-reused 0 - Unpacking objects: 100% (37/37), done. - Checking connectivity... done. - directory_name [Hello]: Fabulous - file_name [Howdy]: Zing - greeting_recipient [Julie]: Roscoe - - $ cd Fabulous - - $ python Zing.py - Hello, Roscoe! - -Same effect, but this time produced from the Internet! You'll notice that even -though it says ``Cloning into 'HelloCookieCutter1'...``, you don't see any -directory called ``HelloCookieCutter1`` in your local directory. Cookiecutter -has its own storage area for cookiecutters, which is in your home directory -in a subdirectory called ``.cookiecutters`` (the leading ``.`` hides the directory -on most operating systems). You don't need to do anything with this directory -but it can sometimes be useful to know where it is. - -Now if you ever find yourself duplicating effort when starting new projects, -you'll know how to eliminate that duplication using cookiecutter. But even -better, lots of people have created and published cookiecutters, so when you -are starting a new project, make sure you look at the `list of pre-defined -cookiecutters -`_ -first! - -.. [1] You can also run *hooks* before and/or after generation, but that's - more complex than what we want to cover here. diff --git a/docs/HelloCookieCutter1/cookiecutter.json b/docs/HelloCookieCutter1/cookiecutter.json deleted file mode 100644 index ca7500fa1..000000000 --- a/docs/HelloCookieCutter1/cookiecutter.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "directory_name": "Hello", - "file_name": "Howdy", - "greeting_recipient": "Julie" -} \ No newline at end of file diff --git a/docs/HelloCookieCutter1/{{cookiecutter.directory_name}}/{{cookiecutter.file_name}}.py b/docs/HelloCookieCutter1/{{cookiecutter.directory_name}}/{{cookiecutter.file_name}}.py deleted file mode 100644 index 1d2ab57cb..000000000 --- a/docs/HelloCookieCutter1/{{cookiecutter.directory_name}}/{{cookiecutter.file_name}}.py +++ /dev/null @@ -1 +0,0 @@ -print("Hello, {{cookiecutter.greeting_recipient}}!") \ No newline at end of file From d00e4240992705361f932e9d4d0cd343065e9812 Mon Sep 17 00:00:00 2001 From: Christo De Lange Date: Fri, 3 Feb 2023 16:53:56 -0500 Subject: [PATCH 35/51] fix: Tests broken by progress since this feature was developed --- cookiecutter/context.py | 7 +++- .../{{cookiecutter.repo_name}}/file | 0 tests/test_context.py | 27 +++++++------- tests/test_main.py | 37 +++++++++++++++---- 4 files changed, 50 insertions(+), 21 deletions(-) create mode 100644 tests/test-generate-context-v2/min-v2-cookiecutter/{{cookiecutter.repo_name}}/file diff --git a/cookiecutter/context.py b/cookiecutter/context.py index 6818c673e..5fcf3ee2c 100644 --- a/cookiecutter/context.py +++ b/cookiecutter/context.py @@ -21,7 +21,9 @@ import logging import collections import json +import posix import re +import sys import click from jinja2 import Environment @@ -556,7 +558,10 @@ def load_context(json_object, no_input=False, verbose=True): break if verbose: - width, _ = click.get_terminal_size() + if sys.stdout.isatty(): + width, _ = posix.get_terminal_size() + else: + width = 80 click.echo('-' * width) context[variable.name] = deserialize(value) 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_context.py b/tests/test_context.py index 28bc5f184..3ea99cf31 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -583,19 +583,20 @@ def test_variable_str(): validation_flags=['ignorecase'], hide_input=True) - 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) + 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) diff --git a/tests/test_main.py b/tests/test_main.py index fc4eb96fa..c7f25d52a 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,4 +1,10 @@ """Collection of tests around cookiecutter's replay feature.""" +import collections +import os +import pathlib +import shutil + +from cookiecutter.find import find_template from cookiecutter.main import cookiecutter @@ -71,13 +77,28 @@ def test_version_2_load_context_call( 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') - mock_version_1_prompt_for_config = mocker.patch( - 'cookiecutter.main.prompt_for_config') - mock_version_2_load_context = mocker.patch( - 'cookiecutter.main.load_context') + 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( '.', @@ -86,9 +107,11 @@ def test_version_2_load_context_call( config_file=user_config_file, ) - assert mock_version_1_prompt_for_config.call_count == 0 - assert mock_version_2_load_context.call_count == 1 + 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): From 09b5c9716f040a36d70cb0879fd170597e89e224 Mon Sep 17 00:00:00 2001 From: Christo De Lange Date: Fri, 3 Feb 2023 17:04:05 -0500 Subject: [PATCH 36/51] fix: lint checks --- cookiecutter/context.py | 81 +- cookiecutter/generate.py | 58 +- cookiecutter/main.py | 8 +- tests/test_context.py | 215 +++-- tests/test_generate_context_v2.py | 1338 +++++++++++++++++++---------- tests/test_main.py | 17 +- 6 files changed, 1132 insertions(+), 585 deletions(-) diff --git a/cookiecutter/context.py b/cookiecutter/context.py index 5fcf3ee2c..6b65c6b3a 100644 --- a/cookiecutter/context.py +++ b/cookiecutter/context.py @@ -67,8 +67,7 @@ def context_is_version_2(cookiecutter_context): # 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: + if (cookiecutter_context.keys() & SET_OF_REQUIRED_FIELDS) == SET_OF_REQUIRED_FIELDS: return True else: return False @@ -186,16 +185,17 @@ def prompt_yes_no(variable, default): 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) + (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)), - )) + 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( @@ -336,7 +336,9 @@ def __init__(self, name, default, **info): self.description = self.check_type('description', None, str) # -- PROMPT ---------------------------------------------------------- - self.prompt = self.check_type('prompt', DEFAULT_PROMPT.format(variable=self), str) + self.prompt = self.check_type( + 'prompt', DEFAULT_PROMPT.format(variable=self), str + ) # -- HIDE_INPUT ------------------------------------------------------ self.hide_input = self.check_type('hide_input', False, bool) @@ -345,9 +347,11 @@ def __init__(self, name, default, **info): 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)) + 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) @@ -380,7 +384,11 @@ def __init__(self, name, default, **info): 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)) + raise ValueError( + msg.format( + var_name=self.name, default=self.default, choices=self.choices + ) + ) # -- VALIDATION STARTS ----------------------------------------------- self.validation = self.check_type('validation', None, str) @@ -395,10 +403,15 @@ def __init__(self, name, default, **info): 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())) + 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 @@ -407,8 +420,9 @@ def __init__(self, name, default, **info): 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)) + raise ValueError( + msg.format(var_name=self.name, value=self.validation, err=e) + ) # -- VALIDATION ENDS ------------------------------------------------- def __repr__(self): @@ -418,7 +432,11 @@ def __repr__(self): ) def __str__(self): - s = ["{key}='{value}'".format(key=key, value=self.__dict__[key]) for key in self.__dict__ if key != 'info'] + 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): @@ -431,7 +449,14 @@ def check_type(self, option_name, option_default_value, option_type): 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)) + raise ValueError( + msg.format( + var_name=self.name, + opt_name=option_name, + type_name=option_type.__name__, + value=option_value, + ) + ) return option_value @@ -498,7 +523,7 @@ def load_context(json_object, no_input=False, verbose=True): if True, no input prompts are made, all defaults are accepted. :param verbose: Emit maximum varible information. """ - env = Environment(extensions=['jinja2_time.TimeExtension']) + env = Environment(extensions=['jinja2_time.TimeExtension'], autoescape=True) context = collections.OrderedDict({}) skip_to_variable_name = None @@ -549,7 +574,9 @@ def load_context(json_object, no_input=False, verbose=True): if variable.validate.match(value): break else: - msg = "Input validation failure against regex: '{val_string}', try again!".format(val_string=variable.validation) + 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) @@ -573,6 +600,10 @@ def load_context(json_object, no_input=False, verbose=True): 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)) + 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 451b0260e..2e948494f 100644 --- a/cookiecutter/generate.py +++ b/cookiecutter/generate.py @@ -23,7 +23,7 @@ from cookiecutter.hooks import run_hook from cookiecutter.utils import make_sure_path_exists, rmtree, work_in -from .context import context_is_version_2 +from cookiecutter.context import context_is_version_2 logger = logging.getLogger(__name__) @@ -80,13 +80,15 @@ def apply_overwrites_to_context(context, overwrite_context): 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 + 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'] @@ -108,7 +110,8 @@ def apply_default_overwrites_to_context_v2(context, overwrite_default_context): 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 @@ -122,14 +125,18 @@ def resolve_changed_variable_names(context, variables_to_resolve): 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 + 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 an 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 + variable[field_name][i] = item.replace( + var_name_to_resolve, new_var_name + ) # noqa def apply_overwrites_to_context_v2(context, extra_context): @@ -221,16 +228,25 @@ def apply_overwrites_to_context_v2(context, extra_context): except IndexError: replace_name = None - var_dict = next((d for d in context['variables'] if d['name'] == xtra_ctx_name), None) # noqa + 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 + 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 + raise ValueError( + "Cannot remove mandatory 'default' field" + ) # noqa var_dict.pop(key, None) else: # normal field update @@ -240,31 +256,39 @@ def apply_overwrites_to_context_v2(context, extra_context): # 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 + 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'].remove(var_dict['default']) # noqa var_dict['choices'].insert(0, var_dict['default']) - if ('default' not in common_keys) & ('choices' in common_keys): # noqa + 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 + 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 + 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 + 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 diff --git a/cookiecutter/main.py b/cookiecutter/main.py index 20358f57c..adca601aa 100644 --- a/cookiecutter/main.py +++ b/cookiecutter/main.py @@ -17,7 +17,7 @@ from cookiecutter.repository import determine_repo_dir from cookiecutter.utils import rmtree -from .context import context_is_version_2, load_context +from cookiecutter.context import context_is_version_2, load_context logger = logging.getLogger(__name__) @@ -106,9 +106,9 @@ def cookiecutter( # prompt the user to manually configure at the command line. # except when 'no-input' flag is set if context_is_version_2(context['cookiecutter']): - context['cookiecutter'] = load_context(context[u'cookiecutter'], - no_input=no_input, - verbose=True) + 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) diff --git a/tests/test_context.py b/tests/test_context.py index 3ea99cf31..2d7b0db1e 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -20,9 +20,7 @@ from cookiecutter import context -from cookiecutter.exceptions import ( - ContextDecodingException -) +from cookiecutter.exceptions import ContextDecodingException import click @@ -44,7 +42,8 @@ def load_cookiecutter(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)) + ' error details: "{1}"'.format(full_fpath, json_exc_message) + ) raise ContextDecodingException(our_exc_message) # Add the Python object to the context dictionary @@ -58,43 +57,45 @@ def load_cookiecutter(cookiecutter_file): def context_data_check(): context_all_reqs = ( { - 'cookiecutter_context': OrderedDict([ - ("name", "cookiecutter-pytest-plugin"), - ("cookiecutter_version", "2.0.0"), - ("variables", []) - ]) + 'cookiecutter_context': OrderedDict( + [ + ("name", "cookiecutter-pytest-plugin"), + ("cookiecutter_version", "2.0.0"), + ("variables", []), + ] + ) }, - True + True, ) context_missing_name = ( { - 'cookiecutter_context': OrderedDict([ - ("cookiecutter_version", "2.0.0"), - ("variables", []) - ]) + 'cookiecutter_context': OrderedDict( + [("cookiecutter_version", "2.0.0"), ("variables", [])] + ) }, - False + False, ) context_missing_cookiecutter_version = ( { - 'cookiecutter_context': OrderedDict([ - ("name", "cookiecutter-pytest-plugin"), - ("variables", []) - ]) + 'cookiecutter_context': OrderedDict( + [("name", "cookiecutter-pytest-plugin"), ("variables", [])] + ) }, - False + False, ) context_missing_variables = ( { - 'cookiecutter_context': OrderedDict([ - ("name", "cookiecutter-pytest-plugin"), - ("cookiecutter_version", "2.0.0"), - ]) + 'cookiecutter_context': OrderedDict( + [ + ("name", "cookiecutter-pytest-plugin"), + ("cookiecutter_version", "2.0.0"), + ] + ) }, - False + False, ) yield context_all_reqs @@ -126,21 +127,23 @@ def test_load_context_defaults(): 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 '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)]))]) + 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') @@ -179,10 +182,10 @@ def test_load_context_defaults_skips_no_branch(): 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_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 cc_cfg['project_file_logging_enabled'] is True # not skipped assert 'project_file_logging_level' not in cc_cfg.keys() # do_if skipped @@ -210,7 +213,10 @@ def test_load_context_defaults_skips_unknown_variable_name_warning(caplog): 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 + 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): @@ -251,7 +257,9 @@ def test_prompt_bool(mocker): m = mocker.Mock() m.side_effect = context.Variable - v = m.side_effect(name='flag', default=False, prompt='Enter a Flag', hide_input=False) + v = m.side_effect( + name='flag', default=False, prompt='Enter a Flag', hide_input=False + ) r = context.prompt_boolean(v, default=False) @@ -262,7 +270,7 @@ def test_prompt_bool(mocker): type=click.BOOL, ) - assert r # EXPECTED_VALUE + assert r # EXPECTED_VALUE def test_prompt_int(mocker): @@ -329,7 +337,9 @@ def test_prompt_uuid(mocker): m = mocker.Mock() m.side_effect = context.Variable - v = m.side_effect(name='uuid', default=None, prompt='Enter a UUID', hide_input=False) + v = m.side_effect( + name='uuid', default=None, prompt='Enter a UUID', hide_input=False + ) r = context.prompt_uuid(v, default=None) @@ -354,7 +364,9 @@ def test_prompt_json(monkeypatch, mocker): ) m = mocker.Mock() m.side_effect = context.Variable - v = m.side_effect(name='json', default=None, prompt='Enter Config', hide_input=False) + v = m.side_effect( + name='json', default=None, prompt='Enter Config', hide_input=False + ) r = context.prompt_json(v, default=None) @@ -369,11 +381,13 @@ def test_prompt_json_bad_json_decode_click_asks_again(mocker, capsys): mocker.patch( 'click.termui.visible_prompt_func', autospec=True, - side_effect=[EXPECTED_BAD_VALUE, EXPECTED_GOOD_VALUE] + 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) + v = m.side_effect( + name='json', default=None, prompt='Enter Config', hide_input=False + ) r = context.prompt_json(v, default=None) @@ -395,7 +409,9 @@ def test_prompt_json_default(mocker): m = mocker.Mock() m.side_effect = context.Variable - v = m.side_effect(name='json', default=None, prompt='Enter Config', hide_input=False) + v = m.side_effect( + name='json', default=None, prompt='Enter Config', hide_input=False + ) r = context.prompt_json(v, default=cfg) @@ -422,7 +438,9 @@ def test_prompt_yes_no_default_no(mocker): m = mocker.Mock() m.side_effect = context.Variable - v = m.side_effect(name='enable_docs', default='n', prompt='Enable docs', hide_input=False) + v = m.side_effect( + name='enable_docs', default='n', prompt='Enable docs', hide_input=False + ) r = context.prompt_yes_no(v, default=False) @@ -433,7 +451,7 @@ def test_prompt_yes_no_default_no(mocker): type=click.BOOL, ) - assert r # EXPECTED_VALUE + assert r # EXPECTED_VALUE def test_prompt_yes_no_default_yes(mocker): @@ -448,7 +466,9 @@ def test_prompt_yes_no_default_yes(mocker): m = mocker.Mock() m.side_effect = context.Variable - v = m.side_effect(name='enable_docs', default='y', prompt='Enable docs', hide_input=False) + v = m.side_effect( + name='enable_docs', default='y', prompt='Enable docs', hide_input=False + ) r = context.prompt_yes_no(v, default=True) @@ -459,7 +479,7 @@ def test_prompt_yes_no_default_yes(mocker): type=click.BOOL, ) - assert r # EXPECTED_VALUE + assert r # EXPECTED_VALUE def test_prompt_choice(mocker): @@ -479,8 +499,13 @@ def test_prompt_choice(mocker): 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) + 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) @@ -500,10 +525,15 @@ 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) + 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) + 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): @@ -515,13 +545,20 @@ def test_variable_invalid_validation_control_flag_is_logged_and_removed(caplog): prompt="Please enter a name for your base python module", type='string', validation='^[a-z_]+$', - validation_flags=['ignorecase', 'forget', ], - hide_input=True) + 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 ( + "Variable: module_name - Ignoring unkown RegEx validation Control Flag named 'forget'" + in caplog.text + ) assert v.validation_flag_names == ['ignorecase'] @@ -529,7 +566,7 @@ def test_variable_invalid_validation_control_flag_is_logged_and_removed(caplog): def test_variable_validation_compile_exception(): VAR_NAME = 'module_name' - BAD_REGEX_STRING = '^[a-z_+$' # Missing a closing square-bracket (]) + BAD_REGEX_STRING = '^[a-z_+$' # Missing a closing square-bracket (]) with pytest.raises(ValueError) as excinfo: context.Variable( @@ -539,9 +576,14 @@ def test_variable_validation_compile_exception(): type='string', validation=BAD_REGEX_STRING, validation_flags=['ignorecase'], - hide_input=True) + 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) + 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(): @@ -553,7 +595,8 @@ def test_variable_forces_no_prompt_for_private_variable_names(): type='string', validation='^[a-z_]+$', validation_flags=['ignorecase'], - hide_input=True) + hide_input=True, + ) assert v.prompt_user is False @@ -567,7 +610,8 @@ def test_variable_repr(): type='string', validation='^[a-z_]+$', validation_flags=['ignorecase'], - hide_input=True) + hide_input=True, + ) assert repr(v) == "" @@ -581,7 +625,8 @@ def test_variable_str(): type='string', validation='^[a-z_]+$', validation_flags=['ignorecase'], - hide_input=True) + hide_input=True, + ) str_v = str(v) assert ':' in str_v @@ -596,7 +641,9 @@ def test_variable_str(): 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 + 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) @@ -607,7 +654,7 @@ def test_variable_str(): def test_variable_option_raise_invalid_type_value_error(): VAR_NAME = 'module_name' - OPT_VALUE_OF_INCORRECT_TYPE = 12 # should be a string + OPT_VALUE_OF_INCORRECT_TYPE = 12 # should be a string with pytest.raises(ValueError) as excinfo: context.Variable( @@ -617,17 +664,21 @@ def test_variable_option_raise_invalid_type_value_error(): type='string', validation=OPT_VALUE_OF_INCORRECT_TYPE, validation_flags=['ignorecase'], - hide_input=True) + 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) + 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=[]) + cct = context.CookiecutterTemplate( + 'cookiecutter_template_repr_test', cookiecutter_version='2.0.0', variables=[] + ) assert repr(cct) == "" @@ -641,7 +692,7 @@ def test_load_context_with_input_chioces(mocker): mocker.patch( 'click.termui.visible_prompt_func', autospec=True, - side_effect=[INPUT_1, INPUT_2, INPUT_3] + side_effect=[INPUT_1, INPUT_2, INPUT_3], ) cc_cfg = context.load_context(cc['cookiecutter_choices'], no_input=False) @@ -659,7 +710,7 @@ def test_load_context_with_input_with_validation_success(mocker): mocker.patch( 'click.termui.visible_prompt_func', autospec=True, - side_effect=[INPUT_1, INPUT_2] + side_effect=[INPUT_1, INPUT_2], ) logger.debug(cc) @@ -679,7 +730,7 @@ def test_load_context_with_input_with_validation_failure(mocker, capsys): mocker.patch( 'click.termui.visible_prompt_func', autospec=True, - side_effect=[INPUT_1, INPUT_2, INPUT_3] + side_effect=[INPUT_1, INPUT_2, INPUT_3], ) cc_cfg = context.load_context(cc['cookiecutter_val_failure'], no_input=False) @@ -702,7 +753,7 @@ def test_load_context_with_input_with_validation_failure_msg(mocker, capsys): mocker.patch( 'click.termui.visible_prompt_func', autospec=True, - side_effect=[INPUT_1, INPUT_2, INPUT_3] + side_effect=[INPUT_1, INPUT_2, INPUT_3], ) cc_cfg = context.load_context(cc['cookiecutter_val_failure_msg'], no_input=False) @@ -725,10 +776,14 @@ def test_specify_if_yes_skip_to_without_yes_no_type(): 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') + 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) + 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(): @@ -737,7 +792,11 @@ def test_specify_if_no_skip_to_without_yes_no_type(): 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') + 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) + 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 index 61088d51a..1edcbb75d 100644 --- a/tests/test_generate_context_v2.py +++ b/tests/test_generate_context_v2.py @@ -18,111 +18,180 @@ def context_data(): context = ( + {'context_file': 'tests/test-generate-context-v2/test.json'}, { - '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")]), - ]) - ]) - } + "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")]), - ]) - ]) - } + '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")]), - ]) - ]) - } + '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")]), - ]) - ]) - } + '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 = ( @@ -131,18 +200,34 @@ def context_data(): '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"]), - ])] - ) - ]) - } + "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_default_not_in_choices = ( @@ -151,25 +236,42 @@ def context_data(): '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")]), - ]) - ]) - } + "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 @@ -200,7 +302,8 @@ def test_generate_context_extra_ctx_invalid(): generate.generate_context( context_file='tests/test-generate-context-v2/test.json', default_context=None, - extra_context='should_be_a_list_or_a_dictionary') + 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) @@ -212,14 +315,13 @@ 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' - ] + 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) + 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) @@ -237,14 +339,15 @@ def test_generate_context_extra_ctx_list_item_dict_missing_name_field(): "default": "Robert Lewis", "prompt": "What's the author's name?", "description": "Please enter the author's full name.", - "type": "string" + "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) + extra_context=xtra_context, + ) msg = "is missing a 'name' key." assert msg in str(excinfo.value) @@ -262,14 +365,15 @@ def test_generate_context_extra_ctx_list_item_dict_no_name_field_match(): "default": "Robert Lewis", "prompt": "What's the author's name?", "description": "Please enter the author's full name.", - "type": "string" + "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) + extra_context=xtra_context, + ) msg = "No variable found in context whose name matches extra context name 'author_name'" assert msg in str(excinfo.value) @@ -287,28 +391,42 @@ def gen_context_data_inputs_expected(): '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")]), - ]) - ]) - } + ], + }, + { + "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 = ( @@ -325,33 +443,62 @@ def gen_context_data_inputs_expected(): # ] }, { - "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") - ]) - ]) - ]) - } + "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 @@ -370,36 +517,61 @@ def gen_context_data_inputs_expected(): '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") - ]) - ]) - ]) - } + ], + }, + { + "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 @@ -414,36 +586,66 @@ def gen_context_data_inputs_expected(): '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") - ]) - ]) - ]) - } + ], + }, + { + "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, @@ -459,36 +661,65 @@ def gen_context_data_inputs_expected(): '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") - ]) - ]) - ]) - } + ], + }, + { + "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 @@ -498,39 +729,76 @@ def gen_context_data_inputs_expected(): 'extra_context': [ { 'name': 'director_name', - 'choices': ['Allan Smithe', 'Ridley Scott', 'Victor Fleming', 'John Houston', - 'John Ford', 'Billy Wilder'], + '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") - ]) - ]) - ]) - } + ], + }, + { + "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 = ( @@ -540,39 +808,76 @@ def gen_context_data_inputs_expected(): { 'name': 'director_name', 'default': 'John Ford', - 'choices': ['Allan Smithe', 'Ridley Scott', 'Victor Fleming', 'John Houston', - 'John Ford', 'Billy Wilder'], + '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") - ]) - ]) - ]) - } + ], + }, + { + "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 @@ -584,36 +889,66 @@ def gen_context_data_inputs_expected(): '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") - ]) - ]) - ]) - } + ], + }, + { + "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 @@ -623,39 +958,76 @@ def gen_context_data_inputs_expected(): 'extra_context': [ { 'name': 'director_name', - 'choices': ['Billy Wilder', 'Allan Smithe', 'Ridley Scott', 'Victor Fleming', 'John Houston', - 'John Ford'], + '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") - ]) - ]) - ]) - } + ], + }, + { + "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. @@ -667,36 +1039,66 @@ def gen_context_data_inputs_expected(): '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") - ]) - ]) - ]) - } + ], + }, + { + "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 @@ -711,10 +1113,12 @@ def gen_context_data_inputs_expected(): @pytest.mark.usefixtures('clean_system') -@pytest.mark.parametrize('input_params, expected_context', - gen_context_data_inputs_expected()) +@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): + input_params, expected_context, monkeypatch +): """ Test the generated context with extra content overwrite to multiple fields, with creation of new fields NOT allowed. @@ -726,35 +1130,60 @@ 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") - ]) - ]) - ]) - } + "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"), + ] + ), + ], + ), + ] + ) + }, ) @@ -777,6 +1206,7 @@ def test_raise_exception_when_attempting_to_remove_mandatory_field(): generate.generate_context( context_file='tests/test-generate-context-v2/representative.json', default_context=None, - extra_context=xtra_context) + 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 c7f25d52a..0af183596 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,10 +1,8 @@ """Collection of tests around cookiecutter's replay feature.""" import collections import os -import pathlib import shutil -from cookiecutter.find import find_template from cookiecutter.main import cookiecutter @@ -68,8 +66,7 @@ def test_replay_load_template_name( ) -def test_version_2_load_context_call( - monkeypatch, mocker, user_config_file): +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 @@ -82,17 +79,23 @@ def test_version_2_load_context_call( 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 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 {} From 392ee47e535a644c84e4c5b7e838bfccffdb2bf6 Mon Sep 17 00:00:00 2001 From: Christo De Lange Date: Fri, 3 Feb 2023 18:15:27 -0500 Subject: [PATCH 37/51] fix: Cover all cases 100% --- cookiecutter/context.py | 8 +- tests/test_context.py | 2 +- tests/test_generate_context_v2.py | 133 +++++++++++++++++++++++++++++- 3 files changed, 135 insertions(+), 8 deletions(-) diff --git a/cookiecutter/context.py b/cookiecutter/context.py index 6b65c6b3a..6120b8d08 100644 --- a/cookiecutter/context.py +++ b/cookiecutter/context.py @@ -21,9 +21,8 @@ import logging import collections import json -import posix import re -import sys +import shutil import click from jinja2 import Environment @@ -585,10 +584,7 @@ def load_context(json_object, no_input=False, verbose=True): break if verbose: - if sys.stdout.isatty(): - width, _ = posix.get_terminal_size() - else: - width = 80 + width, _ = shutil.get_terminal_size() click.echo('-' * width) context[variable.name] = deserialize(value) diff --git a/tests/test_context.py b/tests/test_context.py index 2d7b0db1e..79b594d2b 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -683,7 +683,7 @@ def test_cookiecutter_template_repr(): assert repr(cct) == "" -def test_load_context_with_input_chioces(mocker): +def test_load_context_with_input_choices(mocker): cc = load_cookiecutter('tests/test-context/cookiecutter_choices.json') INPUT_1 = 'E.R. Uber' diff --git a/tests/test_generate_context_v2.py b/tests/test_generate_context_v2.py index 1edcbb75d..c2cc6de91 100644 --- a/tests/test_generate_context_v2.py +++ b/tests/test_generate_context_v2.py @@ -230,6 +230,43 @@ def context_data(): }, ) + 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', @@ -278,9 +315,88 @@ def context_data(): 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_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): @@ -288,7 +404,22 @@ def test_generate_context(input_params, expected_context): Test the generated context for several input parameters against the according expected context. """ - assert generate.generate_context(**input_params) == 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') From b9a7985a8eccf613dc65bf89997b9a67fbc41d73 Mon Sep 17 00:00:00 2001 From: Christo De Lange Date: Fri, 3 Feb 2023 18:20:23 -0500 Subject: [PATCH 38/51] fix: lint --- tests/test_generate_context_v2.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/test_generate_context_v2.py b/tests/test_generate_context_v2.py index c2cc6de91..223130977 100644 --- a/tests/test_generate_context_v2.py +++ b/tests/test_generate_context_v2.py @@ -354,7 +354,7 @@ def context_data_value_errors(): ] ) }, - False + False, ) context_choices_with_extra_value_error = ( { @@ -391,7 +391,7 @@ def context_data_value_errors(): ] ) }, - True + True, ) yield context_choices_with_default_value_error yield context_choices_with_extra_value_error @@ -409,7 +409,9 @@ def test_generate_context(input_params, expected_context): @pytest.mark.usefixtures('clean_system') -@pytest.mark.parametrize('input_params, expected_context, raise_exception', context_data_value_errors()) +@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 @@ -419,7 +421,7 @@ def test_generate_context_value_error(input_params, expected_context, raise_exce with pytest.raises(ValueError) as excinfo: generate.generate_context(**input_params) else: - generate.generate_context(**input_params) + generate.generate_context(**input_params) @pytest.mark.usefixtures('clean_system') From 8bc3edaee367307f1834f0136e0953fb062fe676 Mon Sep 17 00:00:00 2001 From: Christo De Lange Date: Fri, 3 Feb 2023 18:22:01 -0500 Subject: [PATCH 39/51] fix: workflow trigger --- .github/workflows/tests.yml | 1 + 1 file changed, 1 insertion(+) 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: From 3908278986cb88a185094cb0950ebb8059532834 Mon Sep 17 00:00:00 2001 From: Christo De Lange Date: Fri, 3 Feb 2023 18:27:30 -0500 Subject: [PATCH 40/51] fix: gah --- .gitmodules | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .gitmodules diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..e0372d2b5 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "docs/HelloCookieCutter1"] + path = docs/HelloCookieCutter1 + url = https://github.com/BruceEckel/HelloCookieCutter1 From 4bb0ef8be218899e2e73a12faa5fe4024d722d3f Mon Sep 17 00:00:00 2001 From: Christo De Lange Date: Fri, 3 Feb 2023 18:43:50 -0500 Subject: [PATCH 41/51] docs --- cookiecutter/generate.py | 25 +++++++++++-------------- docs/cookiecutter.rst | 6 +++--- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/cookiecutter/generate.py b/cookiecutter/generate.py index 2e948494f..d60cd4d4e 100644 --- a/cookiecutter/generate.py +++ b/cookiecutter/generate.py @@ -140,8 +140,10 @@ def resolve_changed_variable_names(context, variables_to_resolve): def apply_overwrites_to_context_v2(context, extra_context): - """ - Modify the given version 2 context in place based on 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. @@ -168,20 +170,17 @@ def apply_overwrites_to_context_v2(context, extra_context): 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. Therefore the following - extra context dictionary entry snytax was introduced to allow the ‘name’ + 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', - } + { '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', - } + { 'name': 'director_credit::producer_credit', } + Removing a Field from a Variable -------------------------------- @@ -191,10 +190,8 @@ def apply_overwrites_to_context_v2(context, extra_context): In order to accomplish this a remove field token is used in the extra context as follows: - { - 'name': 'director_cut', - 'skip_if': '<>', - } + { '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. diff --git a/docs/cookiecutter.rst b/docs/cookiecutter.rst index 9e63654c9..f1086a079 100644 --- a/docs/cookiecutter.rst +++ b/docs/cookiecutter.rst @@ -25,9 +25,9 @@ cookiecutter.context module --------------------------- .. automodule:: cookiecutter.context - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: cookiecutter.environment module ------------------------------- From 4881a9c53f1e4c893f4705f23264876a811cf09f Mon Sep 17 00:00:00 2001 From: Christo De Lange Date: Fri, 3 Feb 2023 18:51:13 -0500 Subject: [PATCH 42/51] fix: submodule --- .gitmodules => gitmodules | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .gitmodules => gitmodules (100%) diff --git a/.gitmodules b/gitmodules similarity index 100% rename from .gitmodules rename to gitmodules From 057a3cd6eb1190e548251920696eb6fc281ba888 Mon Sep 17 00:00:00 2001 From: Christo De Lange Date: Fri, 3 Feb 2023 18:54:20 -0500 Subject: [PATCH 43/51] chore: fix submodule --- .gitmodules | 3 +++ docs/HelloCookieCutter1 | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 .gitmodules diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..e0372d2b5 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "docs/HelloCookieCutter1"] + path = docs/HelloCookieCutter1 + url = https://github.com/BruceEckel/HelloCookieCutter1 diff --git a/docs/HelloCookieCutter1 b/docs/HelloCookieCutter1 index 1e4c68092..239ea6928 160000 --- a/docs/HelloCookieCutter1 +++ b/docs/HelloCookieCutter1 @@ -1 +1 @@ -Subproject commit 1e4c680929339bec24cf5c306a1c34e2e4821946 +Subproject commit 239ea692896301eaa280dd407fdd4d5c55cf6998 From 9a3a7bc0e67a56f52a4589f1ea027da3811fdaeb Mon Sep 17 00:00:00 2001 From: Christo De Lange Date: Fri, 3 Feb 2023 19:01:22 -0500 Subject: [PATCH 44/51] fix: obsolete submodule --- .gitmodules | 3 --- gitmodules | 3 --- 2 files changed, 6 deletions(-) delete mode 100644 .gitmodules delete mode 100644 gitmodules diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index e0372d2b5..000000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "docs/HelloCookieCutter1"] - path = docs/HelloCookieCutter1 - url = https://github.com/BruceEckel/HelloCookieCutter1 diff --git a/gitmodules b/gitmodules deleted file mode 100644 index e0372d2b5..000000000 --- a/gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "docs/HelloCookieCutter1"] - path = docs/HelloCookieCutter1 - url = https://github.com/BruceEckel/HelloCookieCutter1 From 9562d775e119fdc0b4e359eb2ca9aae0b6708b56 Mon Sep 17 00:00:00 2001 From: Christo De Lange Date: Fri, 3 Feb 2023 19:02:30 -0500 Subject: [PATCH 45/51] fix: scrub docs --- docs/HelloCookieCutter1 | 1 - 1 file changed, 1 deletion(-) delete mode 160000 docs/HelloCookieCutter1 diff --git a/docs/HelloCookieCutter1 b/docs/HelloCookieCutter1 deleted file mode 160000 index 239ea6928..000000000 --- a/docs/HelloCookieCutter1 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 239ea692896301eaa280dd407fdd4d5c55cf6998 From ad9e7ba2a221318a19d226578bd39d31b1d0c737 Mon Sep 17 00:00:00 2001 From: Christo De Lange Date: Fri, 3 Feb 2023 19:43:29 -0500 Subject: [PATCH 46/51] chore: Cover the deeper cases --- cookiecutter/generate.py | 2 +- .../representative-var.json | 26 +++ tests/test_generate_context_v2.py | 166 ++++++++++++++++++ 3 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 tests/test-generate-context-v2/representative-var.json diff --git a/cookiecutter/generate.py b/cookiecutter/generate.py index d60cd4d4e..f6eb3b2e8 100644 --- a/cookiecutter/generate.py +++ b/cookiecutter/generate.py @@ -130,7 +130,7 @@ def resolve_changed_variable_names(context, variables_to_resolve): ) # noqa elif isinstance(variable[field_name], list): - # a choices field could have an str item to update + # 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: 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..45acc36a5 --- /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": "{{cookiecutter.director_credit}}", + "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.py b/tests/test_generate_context_v2.py index 223130977..0b06330a2 100644 --- a/tests/test_generate_context_v2.py +++ b/tests/test_generate_context_v2.py @@ -1245,6 +1245,158 @@ def gen_context_data_inputs_expected(): 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() @@ -1259,6 +1411,20 @@ def test_generate_context_with_extra_context_dictionary( 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 = ( { From 63633df02dde81f9112a5b15e4aed8a137c93aa0 Mon Sep 17 00:00:00 2001 From: Christo De Lange Date: Fri, 3 Feb 2023 19:56:25 -0500 Subject: [PATCH 47/51] fix: coverage --- tests/test-generate-context-v2/representative-var.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test-generate-context-v2/representative-var.json b/tests/test-generate-context-v2/representative-var.json index 45acc36a5..a83ee70c5 100644 --- a/tests/test-generate-context-v2/representative-var.json +++ b/tests/test-generate-context-v2/representative-var.json @@ -4,7 +4,7 @@ "variables" : [ { "name": "director_credit", - "default": true, + "default": false, "prompt": "Is there a director credit on this film?", "description": "Directors take credit for most of their films, usually...", "type": "boolean" From 8e705be4f9049c6c704bd40bcafe50bee55aa60e Mon Sep 17 00:00:00 2001 From: Christo De Lange Date: Sat, 4 Feb 2023 08:32:35 -0500 Subject: [PATCH 48/51] fix: Cover the uncovered branches --- cookiecutter/context.py | 3 +- setup.cfg | 2 +- tests/test-context/cookiecutter.json | 12 ++ tests/test-context/cookiecutter_skips_1.json | 13 ++ .../representative-director.json | 36 ++++++ .../representative-var.json | 6 +- .../test_choices-miss.json | 17 +++ tests/test_context.py | 19 +++ tests/test_generate_context_v2.py | 113 +++++++++++++++++- 9 files changed, 214 insertions(+), 7 deletions(-) create mode 100644 tests/test-generate-context-v2/representative-director.json create mode 100644 tests/test-generate-context-v2/test_choices-miss.json diff --git a/cookiecutter/context.py b/cookiecutter/context.py index 6120b8d08..41d3c5156 100644 --- a/cookiecutter/context.py +++ b/cookiecutter/context.py @@ -540,7 +540,8 @@ def load_context(json_object, no_input=False, verbose=True): if variable.skip_if: skip_template = env.from_string(variable.skip_if) - if skip_template.render(cookiecutter=context) == 'True': + skip_value = skip_template.render(cookiecutter=context) + if skip_value == 'True': continue if variable.do_if: 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 index 72f91ed22..eb746bb4d 100644 --- a/tests/test-context/cookiecutter.json +++ b/tests/test-context/cookiecutter.json @@ -88,6 +88,18 @@ ], "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' %}", diff --git a/tests/test-context/cookiecutter_skips_1.json b/tests/test-context/cookiecutter_skips_1.json index 6e78c1c8a..523ee217f 100644 --- a/tests/test-context/cookiecutter_skips_1.json +++ b/tests/test-context/cookiecutter_skips_1.json @@ -57,6 +57,19 @@ ], "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, 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 index a83ee70c5..3be4d675a 100644 --- a/tests/test-generate-context-v2/representative-var.json +++ b/tests/test-generate-context-v2/representative-var.json @@ -4,19 +4,19 @@ "variables" : [ { "name": "director_credit", - "default": false, + "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": "{{cookiecutter.director_credit}}", + "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"], + "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}}", 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_context.py b/tests/test_context.py index 79b594d2b..868fd05bf 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -702,6 +702,25 @@ def test_load_context_with_input_choices(mocker): 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') diff --git a/tests/test_generate_context_v2.py b/tests/test_generate_context_v2.py index 0b06330a2..1f45f8dc6 100644 --- a/tests/test_generate_context_v2.py +++ b/tests/test_generate_context_v2.py @@ -319,6 +319,83 @@ def context_data(): 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 = ( { @@ -408,6 +485,17 @@ def test_generate_context(input_params, expected_context): 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() @@ -639,7 +727,7 @@ def gen_context_data_inputs_expected(): # a key from the context via the removal token: '<>' context_with_valid_extra_2 = ( { - 'context_file': 'tests/test-generate-context-v2/representative.json', + 'context_file': 'tests/test-generate-context-v2/representative-director.json', 'extra_context': [ { 'name': 'director_credit::producer_credit', @@ -653,7 +741,7 @@ def gen_context_data_inputs_expected(): ], }, { - "representative": OrderedDict( + "representative-director": OrderedDict( [ ("name", "cc-representative"), ("cookiecutter_version", "2.0.0"), @@ -675,6 +763,27 @@ def gen_context_data_inputs_expected(): ("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"), From 5225f5f45407b015e89c30c4f7a2fc4dcf75405a Mon Sep 17 00:00:00 2001 From: Christo De Lange Date: Sat, 4 Feb 2023 08:35:23 -0500 Subject: [PATCH 49/51] fix: lint --- tests/test_context.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_context.py b/tests/test_context.py index 868fd05bf..4cc32feaf 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -714,7 +714,9 @@ def test_load_context_with_input_choices_no_verbose(mocker): side_effect=[INPUT_1, INPUT_2, INPUT_3], ) - cc_cfg = context.load_context(cc['cookiecutter_choices'], no_input=False, verbose=False) + 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 From f7c2acf0ae5364b8c2414a8757ee110e7f60d6a0 Mon Sep 17 00:00:00 2001 From: Christo De Lange Date: Sat, 4 Feb 2023 08:44:02 -0500 Subject: [PATCH 50/51] fix: All new versions hit this - bypassing for py3.8 --- cookiecutter/context.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cookiecutter/context.py b/cookiecutter/context.py index 41d3c5156..5e8f45b12 100644 --- a/cookiecutter/context.py +++ b/cookiecutter/context.py @@ -582,7 +582,8 @@ def load_context(json_object, no_input=False, verbose=True): click.echo(variable.validation_msg) else: # no validation defined - break + # Only py3.8 does not detect coverage here + break # pragma: no cover if verbose: width, _ = shutil.get_terminal_size() From 7d4c852f806c9b9acf5fe807744b2e8fad71e79a Mon Sep 17 00:00:00 2001 From: Christo De Lange Date: Sat, 4 Feb 2023 08:51:26 -0500 Subject: [PATCH 51/51] fix: Cheat the peephole optimizer for py3.8 --- cookiecutter/context.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cookiecutter/context.py b/cookiecutter/context.py index 5e8f45b12..389cda653 100644 --- a/cookiecutter/context.py +++ b/cookiecutter/context.py @@ -581,9 +581,12 @@ def load_context(json_object, no_input=False, verbose=True): 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 - # Only py3.8 does not detect coverage here - break # pragma: no cover + break if verbose: width, _ = shutil.get_terminal_size()