8000 Implement new style for nested templates config by ericof · Pull Request #1981 · cookiecutter/cookiecutter · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

Implement new style for nested templates config #1981

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Nov 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 8 additions & 10 deletions cookiecutter/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
"""
import logging
import os
import re
import sys
from copy import copy
from pathlib import Path
Expand All @@ -15,6 +14,7 @@
from cookiecutter.exceptions import InvalidModeException
from cookiecutter.generate import generate_context, generate_files
from cookiecutter.hooks import run_pre_prompt_hook
from cookiecutter.prompt import choose_nested_template
from cookiecutter.prompt import prompt_for_config
from cookiecutter.replay import dump, load
from cookiecutter.repository import determine_repo_dir
Expand Down Expand Up @@ -138,16 +138,10 @@ def cookiecutter(
# except when 'no-input' flag is set

with import_patch:
if context_for_prompting['cookiecutter']:
context['cookiecutter'].update(
prompt_for_config(context_for_prompting, no_input)
)
if "template" in context["cookiecutter"]:
nested_template = re.search(
r'\((.*?)\)', context["cookiecutter"]["template"]
).group(1)
if {"template", "templates"} & set(context["cookiecutter"].keys()):
nested_template = choose_nested_template(context, repo_dir, no_input)
return cookiecutter(
template=os.path.join(repo_dir, nested_template),
template=nested_template,
checkout=checkout,
no_input=no_input,
extra_context=extra_context,
Expand All @@ -162,6 +156,10 @@ def cookiecutter(
accept_hooks=accept_hooks,
keep_project_on_failure=keep_project_on_failure,
)
if context_for_prompting['cookiecutter']:
context['cookiecutter'].update(
prompt_for_config(context_for_prompting, no_input)
)

logger.debug('context is %s', context)

Expand Down
67 changes: 61 additions & 6 deletions cookiecutter/prompt.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""Functions for prompting the user for project info."""
import json
import re
from collections import OrderedDict
from pathlib import Path

from rich.prompt import Prompt, Confirm, PromptBase, InvalidResponse
from jinja2.exceptions import UndefinedError
Expand Down Expand Up @@ -217,6 +219,27 @@ def render_variable(env, raw, cookiecutter_dict):
return template.render(cookiecutter=cookiecutter_dict)


def _prompts_from_options(options: dict) -> dict:
"""Process template options and return friendly prompt information."""
prompts = {"__prompt__": "Select a template"}
for option_key, option_value in options.items():
title = str(option_value.get("title", option_key))
description = option_value.get("description", option_key)
label = title if title == description else f"{title} ({description})"
prompts[option_key] = label
return prompts


def prompt_choice_for_template(key, options, no_input):
"""Prompt user with a set of options to choose from.

:param no_input: Do not prompt for user input and return the first available option.
"""
opts = list(options.keys())
prompts = {"templates": _prompts_from_options(options)}
return opts[0] if no_input else read_user_choice(key, opts, prompts, "")


def prompt_choice_for_config(
cookiecutter_dict, env, key, options, no_input, prompts=None, prefix=""
):
Expand All @@ -238,16 +261,11 @@ def prompt_for_config(context, no_input=False):
"""
cookiecutter_dict = OrderedDict([])
env = StrictEnvironment(context=context)

prompts = {}
if '__prompts__' in context['cookiecutter'].keys():
prompts = context['cookiecutter']['__prompts__']
del context['cookiecutter']['__prompts__']
prompts = context['cookiecutter'].pop('__prompts__', {})

# First pass: Handle simple and raw variables, plus choices.
# These must be done first because the dictionaries keys and
# values might refer to them.

count = 0
all_prompts = context['cookiecutter'].items()
visible_prompts = [k for k, _ in all_prompts if not k.startswith("_")]
Expand Down Expand Up @@ -313,3 +331,40 @@ def prompt_for_config(context, no_input=False):
raise UndefinedVariableInTemplate(msg, err, context) from err

return cookiecutter_dict


def choose_nested_template(context: dict, repo_dir: str, no_input: bool = False) -> str:
"""Prompt user to select the nested template to use.

:param context: Source for field names and sample values.
:param repo_dir: Repository directory.
:param no_input: Do not prompt for user input and use only values from context.
:returns: Path to the selected template.
"""
cookiecutter_dict = OrderedDict([])
env = StrictEnvironment(context=context)
prefix = ""
prompts = context['cookiecutter'].pop('__prompts__', {})
key = "templates"
config = context['cookiecutter'].get(key, {})
if config:
# Pass
val = prompt_choice_for_template(key, config, no_input)
template = config[val]["path"]
else:
# Old style
key = "template"
config = context['cookiecutter'].get(key, [])
val = prompt_choice_for_config(
cookiecutter_dict, env, key, config, no_input, prompts, prefix
)
template = re.search(r'\((.+)\)', val).group(1)

template = Path(template) if template else None
if not (template and not template.is_absolute()):
raise ValueError("Illegal template path")

repo_dir = Path(repo_dir).resolve()
template_path = (repo_dir / template).resolve()
# Return path as string
return f"{template_path}"
50 changes: 45 additions & 5 deletions docs/advanced/nested_config_files.rst
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
.. _nested-config-files:

Nested configuration files
----------------------------------------------
--------------------------

*New in Cookiecutter 2.5.0*

If you wish to create a hierarchy of templates and use cookiecutter to choose among them,
you need just to specify the key ``template`` in the main configuration file to reach
you need just to specify the key ``templates`` in the main configuration file to reach
the other ones.

Let's imagine to have the following structure::
Expand All @@ -14,7 +16,7 @@ Let's imagine to have the following structure::
│ ├── cookiecutter.json
│ ├── {{cookiecutter.project_slug}}
| │ ├── ...
├── project-2
├── package
│ ├── cookiecutter.json
│ ├── {{cookiecutter.project_slug}}
| │ ├── ...
Expand All @@ -23,6 +25,44 @@ Let's imagine to have the following structure::
It is possible to specify in the main ``cookiecutter.json`` how to reach the other
config files as follows:

.. code-block:: JSON

{
"templates": {
"project-1": {
"path": "./project-1",
"title": "Project 1",
"description": "A cookiecutter template for a project"
},
"package": {
"path": "./package",
"title": "Package",
"description": "A cookiecutter template for a package"
}
}
}

Then, when ``cookiecutter`` is launched in the main directory it will ask to choose
among the possible templates:

.. code-block::

Select template:
1 - Project 1 (A cookiecutter template for a project)
2 - Package (A cookiecutter template for a package)
Choose from 1, 2 [1]:

Once a template is chosen, for example ``1``, it will continue to ask the info required by
``cookiecutter.json`` in the ``project-1`` folder, such as ``project-slug``


Old Format
++++++++++

*New in Cookiecutter 2.2.0*

In the main ``cookiecutter.json`` add a `template` key with the following format:

.. code-block:: JSON

{
Expand All @@ -32,10 +72,10 @@ config files as follows:
]
}

Then, when ``cookiecutter`` is launched in the main directory it will ask to choice
Then, when ``cookiecutter`` is launched in the main directory it will ask to choose
among the possible templates:

.. code-block:: bash
.. code-block::

Select template:
1 - Project 1 (./project-1)
Expand Down
11 changes: 11 additions & 0 deletions tests/fake-nested-templates-old-style/cookiecutter.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"template": [
"fake-package (./fake-package)"
],
"__prompts__": {
"template": {
"__prompt__": "Select a template",
"fake-package (./fake-package)": "Fake Package"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
15 changes: 12 additions & 3 deletions tests/fake-nested-templates/cookiecutter.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
{
"template": [
"fake-project (fake-project)"
]
"templates": {
"fake-project": {
"path": "./fake-project",
"title": "A Fake Project",
"description": "A cookiecutter template for a project"
},
"fake-package": {
"path": "./fake-package",
"title": "A Fake Package",
"description": "A cookiecutter template for a package"
}
}
}
1 change: 1 addition & 0 deletions tests/fake-nested-templates/fake-package/cookiecutter.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
21 changes: 14 additions & 7 deletions tests/test_cookiecutter_nested_templates.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
"""Test cookiecutter invocation with nested configuration structure."""
import os
from pathlib import Path
import pytest

from cookiecutter import main


def test_cookiecutter_nested_templates(mocker):
@pytest.mark.parametrize(
"template_dir,output_dir",
[
["fake-nested-templates", "fake-project"],
["fake-nested-templates-old-style", "fake-package"],
],
)
def test_cookiecutter_nested_templates(mocker, template_dir: str, output_dir: str):
"""Verify cookiecutter nested configuration files mechanism."""
mock_generate_files = mocker.patch("cookiecutter.main.generate_files")
main_dir = os.path.join("tests", "fake-nested-templates")
main.cookiecutter(main_dir, no_input=True)
assert mock_generate_files.call_args[1]["repo_dir"] == os.path.join(
main_dir, "fake-project"
)
main_dir = (Path("tests") / template_dir).resolve()
main.cookiecutter(f"{main_dir}", no_input=True)
expected = (Path(main_dir) / output_dir).resolve()
assert mock_generate_files.call_args[1]["repo_dir"] == f"{expected}"
67 changes: 67 additions & 0 deletions tests/test_prompt.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
"""Tests for `cookiecutter.prompt` module."""
import platform
import sys

from collections import OrderedDict
from pathlib import Path

import click
import json
import pytest

from cookiecutter import prompt, exceptions, environment
Expand Down Expand Up @@ -573,3 +577,66 @@ def test_undefined_variable(context):
error = err.value
assert error.message == "Unable to render variable 'foo'"
assert error.context == context


@pytest.mark.parametrize(
"template_dir,expected",
[
["fake-nested-templates", "fake-project"],
["fake-nested-templates-old-style", "fake-package"],
],
)
def test_cookiecutter_nested_templates(template_dir: str, expected: str):
"""Test nested_templates generation."""
from cookiecutter import prompt

main_dir = (Path("tests") / template_dir).resolve()
cookiecuter_context = json.loads((main_dir / "cookiecutter.json").read_text())
context = {"cookiecutter": cookiecuter_context}
output_dir = prompt.choose_nested_template(context, main_dir, no_input=True)
expected = (Path(main_dir) / expected).resolve()
assert output_dir == f"{expected}"


@pytest.mark.skipif(sys.platform.startswith('win'), reason="Linux / macos test")
@pytest.mark.parametrize(
"path",
[
"",
"/tmp",
"/foo",
],
)
def test_cookiecutter_nested_templates_invalid_paths(path: str):
"""Test nested_templates generation."""
from cookiecutter import prompt

main_dir = (Path("tests") / "fake-nested-templates").resolve()
cookiecuter_context = json.loads((main_dir / "cookiecutter.json").read_text())
cookiecuter_context["templates"]["fake-project"]["path"] = path
context = {"cookiecutter": cookiecuter_context}
with pytest.raises(ValueError) as exc:
prompt.choose_nested_template(context, main_dir, no_input=True)
assert "Illegal template path" in str(exc)


@pytest.mark.skipif(not sys.platform.startswith('win'), reason="Win only test")
@pytest.mark.parametrize(
"path",
[
"",
"C:/tmp",
"D:/tmp",
],
)
def test_cookiecutter_nested_templates_invalid_win_paths(path: str):
"""Test nested_templates generation."""
from cookiecutter import prompt

main_dir = (Path("tests") / "fake-nested-templates").resolve()
cookiecuter_context = json.loads((main_dir / "cookiecutter.json").read_text())
cookiecuter_context["templates"]["fake-project"]["path"] = path
context = {"cookiecutter": cookiecuter_context}
with pytest.raises(ValueError) as exc:
prompt.choose_nested_template(context, main_dir, no_input=True)
assert "Illegal template path" in str(exc)
0