Skip to content

Commit 3768af2

Browse files
feat(cli): prompt cpa custom options interactively
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent eff3f7e commit 3768af2

2 files changed

Lines changed: 392 additions & 22 deletions

File tree

packages/create-awesome-python-app/src/create_awesome_python_app/cli.py

Lines changed: 135 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,20 @@
55
import asyncio
66
import os
77
from pathlib import Path
8+
from typing import Any
89

910
import typer
1011
from create_python_app_core import (
12+
ConfigParseError,
13+
CpaCustomOption,
1114
check_for_latest_version,
1215
check_python_version,
1316
create_python_app,
1417
default_cache_dir,
18+
download_repository,
19+
load_cpa_config,
1520
print_env_info,
21+
resolve_source,
1622
)
1723
from rich.console import Console
1824

@@ -33,6 +39,97 @@ def _in_ci() -> bool:
3339
return os.environ.get("CI", "").lower() in {"1", "true", "yes"}
3440

3541

42+
def _template_config_path(source_subdir: str | None, root: Path) -> Path:
43+
cfg_path = root / "cpa.config.json"
44+
if not cfg_path.is_file() and source_subdir:
45+
cfg_path = root / source_subdir / "cpa.config.json"
46+
return cfg_path
47+
48+
49+
def _parse_set_options(set_opt: list[str] | None) -> dict[str, str]:
50+
set_map: dict[str, str] = {}
51+
for item in set_opt or []:
52+
if "=" not in item:
53+
console.print(f"[red]Invalid --set {item} (expected key=value)[/red]")
54+
raise typer.Exit(2)
55+
key, value = item.split("=", 1)
56+
set_map[key] = value
57+
return set_map
58+
59+
60+
def _stringify_option_value(value: Any) -> str:
61+
if value is None:
62+
return ""
63+
if isinstance(value, bool):
64+
return "true" if value else "false"
65+
return str(value)
66+
67+
68+
def _registry_custom_options(items: list[dict[str, Any]]) -> list[CpaCustomOption]:
69+
options: list[CpaCustomOption] = []
70+
for item in items:
71+
key = item.get("key") or item.get("name")
72+
if not key:
73+
continue
74+
options.append(
75+
CpaCustomOption(
76+
key=str(key),
77+
type=str(item.get("type", "string")),
78+
message=str(item.get("message", "")),
79+
default=item.get("default", item.get("initial")),
80+
)
81+
)
82+
return options
83+
84+
85+
def _prompt_custom_options(
86+
template: str,
87+
*,
88+
set_map: dict[str, str],
89+
cache_dir: Path | None,
90+
offline: bool,
91+
registry_options: list[dict[str, Any]] | None = None,
92+
) -> dict[str, str]:
93+
import questionary
94+
95+
source = resolve_source(template, cache_dir=cache_dir)
96+
root = download_repository(source, offline=offline, cache_root=cache_dir)
97+
try:
98+
config = load_cpa_config(_template_config_path(source.subdir, root))
99+
except ConfigParseError as err:
100+
console.print(f"[yellow]Warning: {err}[/yellow]")
101+
return {}
102+
103+
custom_options = config.custom_options
104+
if not custom_options and registry_options:
105+
custom_options = _registry_custom_options(registry_options)
106+
107+
answers: dict[str, str] = {}
108+
blocked_types = {"password", "invisible"}
109+
for option in custom_options:
110+
if option.type in blocked_types:
111+
console.print(
112+
f"[yellow]Warning: skipped blocked custom option {option.key}[/yellow]"
113+
)
114+
continue
115+
if option.key in set_map:
116+
answers[option.key] = set_map[option.key]
117+
continue
118+
initial = set_map.get(option.key, _stringify_option_value(option.default))
119+
message = option.message or option.key
120+
if option.type in {"bool", "boolean", "confirm"}:
121+
answer = questionary.confirm(
122+
message,
123+
default=initial.lower() in {"1", "true", "yes", "on"},
124+
).ask()
125+
else:
126+
answer = questionary.text(message, default=initial).ask()
127+
if answer is None:
128+
raise typer.Exit(1)
129+
answers[option.key] = _stringify_option_value(answer)
130+
return answers
131+
132+
36133
def main() -> None:
37134
"""Console script entrypoint.
38135
@@ -147,6 +244,8 @@ def scaffold(
147244
console.print("[red]--template is required in non-interactive mode[/red]")
148245
raise typer.Exit(2)
149246

247+
set_map = _parse_set_options(set_opt)
248+
150249
from create_awesome_python_app.catalog import (
151250
CatalogResolutionError,
152251
resolve_catalog_spec,
@@ -161,6 +260,10 @@ def scaffold(
161260
console.print(f"[red]{err}[/red]")
162261
raise typer.Exit(2) from err
163262

263+
if pin and "://" in template and "ref=" not in template:
264+
sep = "&" if "?" in template else "?"
265+
template = f"{template}{sep}ref={pin}"
266+
164267
if want_interactive and not addons:
165268
try:
166269
import questionary
@@ -213,9 +316,38 @@ def scaffold(
213316
console.print("[red]questionary not available[/red]")
214317
raise typer.Exit(1) from None
215318

216-
if pin and "://" in template and "ref=" not in template:
217-
sep = "&" if "?" in template else "?"
218-
template = f"{template}{sep}ref={pin}"
319+
if want_interactive:
320+
try:
321+
from create_awesome_python_app.catalog import (
322+
find_template_by_url,
323+
get_catalog_data,
324+
)
325+
326+
interactive_catalog = interactive_catalog or get_catalog_data()
327+
registry_options: list[dict[str, Any]] = []
328+
template_entry = find_template_by_url(interactive_catalog, template)
329+
if template_entry:
330+
raw_registry_options = (
331+
template_entry.get("customOptions")
332+
or template_entry.get("custom_options")
333+
or []
334+
)
335+
if isinstance(raw_registry_options, list):
336+
registry_options = [
337+
item for item in raw_registry_options if isinstance(item, dict)
338+
]
339+
custom_answers = _prompt_custom_options(
340+
template,
341+
set_map=set_map,
342+
cache_dir=cache_dir,
343+
offline=offline,
344+
registry_options=registry_options,
345+
)
346+
except ImportError:
347+
console.print("[red]questionary not available[/red]")
348+
raise typer.Exit(1) from None
349+
custom_answers.update(set_map)
350+
set_map = custom_answers
219351

220352
# version check
221353
latest = asyncio.run(check_for_latest_version("create-awesome-python-app"))
@@ -230,14 +362,6 @@ def scaffold(
230362
raise typer.Exit(1)
231363
console.print(f"[yellow]{msg}[/yellow]")
232364

233-
set_map: dict[str, str] = {}
234-
for item in set_opt or []:
235-
if "=" not in item:
236-
console.print(f"[red]Invalid --set {item} (expected key=value)[/red]")
237-
raise typer.Exit(2)
238-
k, v = item.split("=", 1)
239-
set_map[k] = v
240-
241365
asyncio.run(
242366
create_python_app(
243367
project_directory or "my-project",

0 commit comments

Comments
 (0)