Crash when prompt mapping keys are unhashable option values
Summary
The read_user_choice function in cookiecutter/prompt.py crashes with a TypeError when option values are unhashable types (like dictionaries or lists) and custom prompts are provided through CLI. This happens because the source code tries to use the option value directly as a dictionary key when checking if p in prompts[var_name], but unhashable types cannot be used as dict keys.
Steps to Reproduce
I received a TypeError: unhashable type: 'dict' crash when using cookiecutter through CLI when creating a template from a json file, using these commands:
mkdir -p /tmp/my-cloud-template/{{cookiecutter.project_name}}
cd /tmp/my-cloud-template
cat > cookiecutter.json << 'EOF'
{
"project_name": "my-cloud-project",
"cloud_config": [
{"provider": "aws", "region": "us-east-1", "instances": 3},
{"provider": "gcp", "region": "us-central1", "instances": 2}
],
"__prompts__": {
"cloud_config": {
"__prompt__": "Select cloud configuration",
"{'provider': 'aws', 'region': 'us-east-1', 'instances': 3}": "AWS East",
"{'provider': 'gcp', 'region': 'us-central1', 'instances': 2}": "GCP Central"
}
}
}
EOF
cd /tmp
cookiecutter my-cloud-templateThen I decided to investigate the error further, and it comes from the read_user_choice function. As an example, when executing the following Python code with the function read_user_choice unchanged in relation to the codesource also produces the same TypeError: unhashable type: 'dict' crash that I had when using the CLI:
from collections import OrderedDict
from itertools import starmap
from typing import Iterator
from rich.prompt import Prompt
# This is the original function from cookiecutter source:
def read_user_choice(var_name: str, options: list, prompts=None, prefix: str = ""):
"""Prompt the user to choose from several options for the given variable.
The first item will be returned if no input happens.
:param var_name: Variable as specified in the context
:param list options: Sequence of options that are available to select from
:return: Exactly one item of ``options`` that has been chosen by the user
"""
if not options:
raise ValueError
choice_map = OrderedDict((f'{i}', value) for i, value in enumerate(options, 1))
choices = choice_map.keys()
question = f"Select {var_name}"
choice_lines: Iterator[str] = starmap(
" [bold magenta]{}[/] - [bold]{}[/]".format, choice_map.items()
)
if prompts and var_name in prompts:
if isinstance(prompts[var_name], str):
question = prompts[var_name]
else:
if "__prompt__" in prompts[var_name]:
question = prompts[var_name]["__prompt__"]
choice_lines = (
f" [bold magenta]{i}[/] - [bold]{prompts[var_name][p]}[/]"
if p in prompts[var_name]
else f" [bold magenta]{i}[/] - [bold]{p}[/]"
for i, p in choice_map.items()
)
prompt = '\n'.join(
(
f"{prefix}{question}",
"\n".join(choice_lines),
" Choose from",
)
)
user_choice = Prompt.ask(prompt, choices=list(choices), default=next(iter(choices)))
return choice_map[user_choice]
# I want to choose from preset cloud configurations:
options = [
{"provider": "aws", "region": "us-east-1", "instances": 3},
{"provider": "gcp", "region": "us-central1", "instances": 2},
{"provider": "azure", "region": "eastus", "instances": 4}
]
prompts = {
"cloud_setup": {
"__prompt__": "Select your cloud deployment configuration",
"{'provider': 'aws', 'region': 'us-east-1', 'instances': 3}": "AWS East (3 nodes)",
"{'provider': 'gcp', 'region': 'us-central1', 'instances': 2}": "GCP Central (2 nodes)",
"{'provider': 'azure', 'region': 'eastus', 'instances': 4}": "Azure East (4 nodes)"
}
}
try:
result = read_user_choice("cloud_setup", options, prompts)
print("creation occurred successfully")
print(result)
except TypeError as e:
print(f"Crashed with error: {e}")
Proposed Fix
To fix the problem, I changed the read_user_choice function to handle option values that can't be used as dictionary keys. It first tries to find a matching label using the raw value, then falls back to using the value converted to a string; and, if none options work, it simply displays the value as a string. I believe this may make Cookiecutter usage more flexible, such as allowing a structured dict containing multiple settings (like cloud provider configs, database setups, framework combinations, etc):
from collections import OrderedDict
from itertools import starmap
from typing import Iterator
from rich.prompt import Prompt
def read_user_choice(var_name: str, options: list, prompts=None, prefix: str = ""):
"""Prompt the user to choose from several options for the given variable.
The first item will be returned if no input happens.
:param var_name: Variable as specified in the context
:param list options: Sequence of options that are available to select from
:return: Exactly one item of ``options`` that has been chosen by the user
"""
if not options:
raise ValueError
choice_map = OrderedDict((f'{i}', value) for i, value in enumerate(options, 1))
choices = choice_map.keys()
question = f"Select {var_name}"
choice_lines: Iterator[str] = starmap(
" [bold magenta]{}[/] - [bold]{}[/]".format, choice_map.items()
)
if prompts and var_name in prompts:
if isinstance(prompts[var_name], str):
question = prompts[var_name]
else:
if "__prompt__" in prompts[var_name]:
question = prompts[var_name]["__prompt__"]
prom_map = prompts[var_name]
def _label_for(value):
try:
label = prom_map.get(value)
except TypeError:
label = None
if label is None:
label = prom_map.get(str(value))
return label if label is not None else str(value)
choice_lines = (
f" [bold magenta]{i}[/] - [bold]{_label_for(value)}[/]"
for i, value in choice_map.items()
)
prompt = '\n'.join(
(
f"{prefix}{question}",
"\n".join(choice_lines),
" Choose from",
)
)
user_choice = Prompt.ask(prompt, choices=list(choices), default=next(iter(choices)))
return choice_map[user_choice]Environment
Please, here follows my environment configuration:
- Cookiecutter 2.6.0
- Python 3.12.0
- Linux (Ubuntu)
Source: cookiecutter/cookiecutter