Skip to content

Commit cbdc138

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

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
@@ -145,6 +242,8 @@ def scaffold(
145242
console.print("[red]--template is required in non-interactive mode[/red]")
146243
raise typer.Exit(2)
147244

245+
set_map = _parse_set_options(set_opt)
246+
148247
from create_awesome_python_app.catalog import (
149248
CatalogResolutionError,
150249
resolve_catalog_spec,
@@ -159,6 +258,10 @@ def scaffold(
159258
console.print(f"[red]{err}[/red]")
160259
raise typer.Exit(2) from err
161260

261+
if pin and "://" in template and "ref=" not in template:
262+
sep = "&" if "?" in template else "?"
263+
template = f"{template}{sep}ref={pin}"
264+
162265
if want_interactive and not addons:
163266
try:
164267
import questionary
@@ -211,9 +314,38 @@ def scaffold(
211314
console.print("[red]questionary not available[/red]")
212315
raise typer.Exit(1) from None
213316

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

218350
# version check
219351
latest = asyncio.run(check_for_latest_version("create-awesome-python-app"))
@@ -228,14 +360,6 @@ def scaffold(
228360
raise typer.Exit(1)
229361
console.print(f"[yellow]{msg}[/yellow]")
230362

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

0 commit comments

Comments
 (0)