Repository: cookiecutter/cookiecutter

Issue:
Nested Config Files: New format for "template" key
* Cookiecutter version: 2.4.0
* Template project URL: https://github.com/plone/cookiecutter-plone/blob/main/cookiecutter.json
* Python version: 3.11
* Operating System: macOs

### Description:

The format for the `template` key requires some additional parsing:

```json
    {
        "template": [
            "Project 1 (./project-1)",
            "Project 2 (./project-2)"
        ]
    }
```

Our proposal is also to support the following format:

```json
    {
        "templates": {
            "project-1": {
                "path": "./project-1",
                "title": "Project 1",
                "description": "A cookiecutter template for a project"
            },
            "project-2": {
                "path": "./package",
                "title": "Package",
                "description": "A cookiecutter template for a package"
            }
        }
    }

```

### Tasks

- [ ] Still support the old format under `template` (but display a deprecation warning)
- [ ] Implement the new format under the `templates` key
- [ ] Support external templates (if possible)




Discussion:

Resolving patch:
diff --git a/cookiecutter/main.py b/cookiecutter/main.py
--- a/cookiecutter/main.py
+++ b/cookiecutter/main.py
@@ -6,7 +6,6 @@
 """
 import logging
 import os
-import re
 import sys
 from copy import copy
 from pathlib import Path
@@ -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
@@ -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,
@@ -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)
 
diff --git a/cookiecutter/prompt.py b/cookiecutter/prompt.py
--- a/cookiecutter/prompt.py
+++ b/cookiecutter/prompt.py
@@ -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
@@ -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=""
 ):
@@ -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("_")]
@@ -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}"


Test patch:
diff --git a/tests/fake-nested-templates-old-style/cookiecutter.json b/tests/fake-nested-templates-old-style/cookiecutter.json
new file mode 100644
--- /dev/null
+++ b/tests/fake-nested-templates-old-style/cookiecutter.json
@@ -0,0 +1,11 @@
+{
+  "template": [
+    "fake-package (./fake-package)"
+  ],
+  "__prompts__": {
+    "template": {
+      "__prompt__": "Select a template",
+      "fake-package (./fake-package)": "Fake Package"
+    }
+  }
+}
diff --git a/tests/fake-nested-templates-old-style/fake-package/cookiecutter.json b/tests/fake-nested-templates-old-style/fake-package/cookiecutter.json
new file mode 100644
--- /dev/null
+++ b/tests/fake-nested-templates-old-style/fake-package/cookiecutter.json
@@ -0,0 +1 @@
+{}
diff --git a/tests/fake-nested-templates/cookiecutter.json b/tests/fake-nested-templates/cookiecutter.json
--- a/tests/fake-nested-templates/cookiecutter.json
+++ b/tests/fake-nested-templates/cookiecutter.json
@@ -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"
+    }
+  }
 }
diff --git a/tests/fake-nested-templates/fake-package/cookiecutter.json b/tests/fake-nested-templates/fake-package/cookiecutter.json
new file mode 100644
--- /dev/null
+++ b/tests/fake-nested-templates/fake-package/cookiecutter.json
@@ -0,0 +1 @@
+{}
diff --git a/tests/test_cookiecutter_nested_templates.py b/tests/test_cookiecutter_nested_templates.py
--- a/tests/test_cookiecutter_nested_templates.py
+++ b/tests/test_cookiecutter_nested_templates.py
@@ -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}"
diff --git a/tests/test_prompt.py b/tests/test_prompt.py
--- a/tests/test_prompt.py
+++ b/tests/test_prompt.py
@@ -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
@@ -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)