Skip to content

Commit 497289c

Browse files
feat(cli): add interactive extension multiselect
Closes #163.
1 parent 4abe752 commit 497289c

4 files changed

Lines changed: 333 additions & 3 deletions

File tree

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

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,18 @@ class TemplateChoice:
3939
search: str
4040

4141

42+
@dataclass(frozen=True)
43+
class ExtensionChoice:
44+
"""Interactive extension choice grouped by catalog category."""
45+
46+
title: str
47+
value: str
48+
search: str
49+
category_slug: str
50+
category_name: str
51+
category_order: int
52+
53+
4254
class CatalogResolutionError(ValueError):
4355
"""Raised when a template or extension slug is not in the catalog."""
4456

@@ -113,6 +125,32 @@ def _search_text(template: dict[str, Any], category_name: str) -> str:
113125
return " ".join(str(token) for token in tokens if token).lower()
114126

115127

128+
def _catalog_category_order(data: dict[str, Any]) -> dict[str, int]:
129+
return {
130+
str(category.get("slug", "")): index
131+
for index, category in enumerate(data.get("categories", []))
132+
}
133+
134+
135+
def _entry_type_values(entry: dict[str, Any]) -> list[str]:
136+
raw_type = entry.get("type", [])
137+
if isinstance(raw_type, str):
138+
return [raw_type]
139+
if isinstance(raw_type, list):
140+
return [str(item) for item in raw_type]
141+
return []
142+
143+
144+
def find_template_by_url(
145+
data: dict[str, Any], template_url: str
146+
) -> dict[str, Any] | None:
147+
"""Return the catalog template entry for a resolved template URL."""
148+
for template in data.get("templates", []):
149+
if isinstance(template, dict) and template.get("url") == template_url:
150+
return template
151+
return None
152+
153+
116154
def build_template_choices(data: dict[str, Any]) -> list[TemplateChoice]:
117155
"""Build CNA-style searchable template choices for interactive mode."""
118156
categories = _category_map(data)
@@ -165,6 +203,67 @@ def build_template_choices(data: dict[str, Any]) -> list[TemplateChoice]:
165203
return choices
166204

167205

206+
def build_extension_choices(
207+
data: dict[str, Any], template_url: str
208+
) -> list[ExtensionChoice]:
209+
"""Build CNA-style extension choices compatible with the selected template."""
210+
categories = _category_map(data)
211+
category_order = _catalog_category_order(data)
212+
template = find_template_by_url(data, template_url)
213+
template_types = _entry_type_values(template or {})
214+
if not template_types:
215+
template_types = ["custom"]
216+
217+
choices: list[ExtensionChoice] = []
218+
for extension in data.get("extensions", data.get("addons", [])):
219+
if not isinstance(extension, dict):
220+
continue
221+
extension_types = _entry_type_values(extension)
222+
if not any(
223+
ext_type in template_types or ext_type == "all"
224+
for ext_type in extension_types
225+
):
226+
continue
227+
extension_url = str(extension.get("url", ""))
228+
if not extension_url:
229+
continue
230+
category_slug = str(extension.get("category", "custom"))
231+
category_name = categories.get(category_slug, category_slug)
232+
labels = extension.get("labels", [])
233+
label_suffix = ""
234+
if isinstance(labels, list) and labels:
235+
label_suffix = " · " + ", ".join(str(label) for label in labels[:3])
236+
description = str(extension.get("description", "")).strip()
237+
description_suffix = f" — {description}" if description else ""
238+
slug = str(extension.get("slug", ""))
239+
title = f"{extension.get('name', slug)} ({slug}){label_suffix}"
240+
choices.append(
241+
ExtensionChoice(
242+
title=f"{title}{description_suffix}",
243+
value=extension_url,
244+
search=_search_text(extension, category_name),
245+
category_slug=category_slug,
246+
category_name=category_name,
247+
category_order=category_order.get(category_slug, len(category_order)),
248+
)
249+
)
250+
251+
return sorted(
252+
choices,
253+
key=lambda choice: (choice.category_order, choice.title.lower()),
254+
)
255+
256+
257+
def group_extension_choices(
258+
choices: list[ExtensionChoice],
259+
) -> dict[str, list[ExtensionChoice]]:
260+
"""Group extension choices by category while preserving sorted order."""
261+
grouped: dict[str, list[ExtensionChoice]] = {}
262+
for choice in choices:
263+
grouped.setdefault(choice.category_slug, []).append(choice)
264+
return grouped
265+
266+
168267
DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/Create-Python-App/cpa-templates/main/templates.json"
169268
CACHE_TTL_SECONDS = 3600
170269
FETCH_TIMEOUT_SECONDS = 10

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

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ def scaffold(
104104
pass # passed to core
105105

106106
want_interactive = interactive if interactive is not None else (not _in_ci())
107+
interactive_catalog: dict[str, object] | None = None
107108
if want_interactive and not template:
108109
try:
109110
import questionary
@@ -114,8 +115,8 @@ def scaffold(
114115
get_catalog_data,
115116
)
116117

117-
catalog = get_catalog_data()
118-
template_choices = build_template_choices(catalog)
118+
interactive_catalog = get_catalog_data()
119+
template_choices = build_template_choices(interactive_catalog)
119120
choice_by_title = {
120121
choice.title: choice.value for choice in template_choices
121122
}
@@ -158,6 +159,58 @@ def scaffold(
158159
console.print(f"[red]{err}[/red]")
159160
raise typer.Exit(2) from err
160161

162+
if want_interactive and not addons:
163+
try:
164+
import questionary
165+
from questionary import Choice
166+
167+
from create_awesome_python_app.catalog import (
168+
build_extension_choices,
169+
get_catalog_data,
170+
group_extension_choices,
171+
)
172+
173+
interactive_catalog = interactive_catalog or get_catalog_data()
174+
extension_choices = build_extension_choices(interactive_catalog, template)
175+
grouped_extensions = group_extension_choices(extension_choices)
176+
if grouped_extensions:
177+
category_choices = [
178+
Choice(
179+
title=(
180+
f"{choices[0].category_name} "
181+
f"({len(choices)} extension"
182+
f"{'s' if len(choices) != 1 else ''})"
183+
),
184+
value=category_slug,
185+
)
186+
for category_slug, choices in grouped_extensions.items()
187+
]
188+
selected_categories = questionary.checkbox(
189+
"Which kinds of extensions do you need?",
190+
choices=category_choices,
191+
qmark="?",
192+
pointer=">",
193+
).ask()
194+
selected_addons: list[str] = []
195+
for category_slug in selected_categories or []:
196+
choices = grouped_extensions.get(str(category_slug), [])
197+
if not choices:
198+
continue
199+
picked = questionary.checkbox(
200+
f"{choices[0].category_name} extensions",
201+
choices=[
202+
Choice(title=choice.title, value=choice.value)
203+
for choice in choices
204+
],
205+
qmark="?",
206+
pointer=">",
207+
).ask()
208+
selected_addons.extend(str(item) for item in picked or [])
209+
addons = selected_addons
210+
except ImportError:
211+
console.print("[red]questionary not available[/red]")
212+
raise typer.Exit(1) from None
213+
161214
if pin and "://" in template and "ref=" not in template:
162215
sep = "&" if "?" in template else "?"
163216
template = f"{template}{sep}ref={pin}"

packages/create-awesome-python-app/tests/test_catalog_resolve.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
from create_awesome_python_app.catalog import (
77
CUSTOM_TEMPLATE_SENTINEL,
88
CatalogResolutionError,
9+
build_extension_choices,
910
build_template_choices,
11+
group_extension_choices,
1012
is_url_like,
1113
resolve_catalog_spec,
1214
resolve_catalog_specs,
@@ -101,3 +103,96 @@ def test_build_template_choices_are_searchable() -> None:
101103
assert "backend" in first.search
102104
assert "uv" in first.search
103105
assert choices[-1].value == CUSTOM_TEMPLATE_SENTINEL
106+
107+
108+
def test_build_extension_choices_filters_by_template_type() -> None:
109+
catalog = {
110+
"categories": [
111+
{"slug": "ci", "name": "CI"},
112+
{"slug": "data", "name": "Data"},
113+
],
114+
"templates": [
115+
{
116+
"slug": "fastapi-starter",
117+
"name": "FastAPI Starter",
118+
"url": "file:///templates/fastapi",
119+
"type": "fastapi-backend",
120+
"category": "backend-applications",
121+
}
122+
],
123+
"extensions": [
124+
{
125+
"slug": "github-setup",
126+
"name": "GitHub Setup",
127+
"description": "Actions and Dependabot",
128+
"url": "file:///extensions/github",
129+
"type": ["fastapi-backend"],
130+
"category": "ci",
131+
"labels": ["GitHub", "CI"],
132+
},
133+
{
134+
"slug": "all-projects",
135+
"name": "All Projects",
136+
"url": "file:///extensions/all",
137+
"type": ["all"],
138+
"category": "data",
139+
},
140+
{
141+
"slug": "django-only",
142+
"name": "Django Only",
143+
"url": "file:///extensions/django",
144+
"type": ["django"],
145+
"category": "ci",
146+
},
147+
],
148+
}
149+
150+
choices = build_extension_choices(catalog, "file:///templates/fastapi")
151+
152+
assert [choice.value for choice in choices] == [
153+
"file:///extensions/github",
154+
"file:///extensions/all",
155+
]
156+
assert "dependabot" in choices[0].search
157+
assert "github" in choices[0].title.lower()
158+
159+
160+
def test_group_extension_choices_preserves_category_order() -> None:
161+
catalog = {
162+
"categories": [
163+
{"slug": "ci", "name": "CI"},
164+
{"slug": "data", "name": "Data"},
165+
],
166+
"templates": [
167+
{
168+
"slug": "fastapi-starter",
169+
"name": "FastAPI Starter",
170+
"url": "file:///templates/fastapi",
171+
"type": "fastapi-backend",
172+
"category": "backend-applications",
173+
}
174+
],
175+
"extensions": [
176+
{
177+
"slug": "postgres",
178+
"name": "Postgres",
179+
"url": "file:///extensions/postgres",
180+
"type": ["fastapi-backend"],
181+
"category": "data",
182+
},
183+
{
184+
"slug": "github",
185+
"name": "GitHub",
186+
"url": "file:///extensions/github",
187+
"type": ["fastapi-backend"],
188+
"category": "ci",
189+
},
190+
],
191+
}
192+
193+
grouped = group_extension_choices(
194+
build_extension_choices(catalog, "file:///templates/fastapi")
195+
)
196+
197+
assert list(grouped) == ["ci", "data"]
198+
assert grouped["ci"][0].value == "file:///extensions/github"
Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1+
import json
12
import os
3+
from pathlib import Path
24

3-
from create_awesome_python_app.cli import _in_ci
5+
from create_awesome_python_app.cli import _in_ci, app
6+
from typer.testing import CliRunner
7+
8+
runner = CliRunner()
49

510

611
def test_in_ci_env(monkeypatch) -> None:
@@ -9,3 +14,81 @@ def test_in_ci_env(monkeypatch) -> None:
914
monkeypatch.delenv("CI", raising=False)
1015
# may still be true in this environment; function checks CI only
1116
os.environ.pop("CI", None)
17+
18+
19+
def test_interactive_extension_selection_passes_addon_urls(
20+
tmp_path: Path, monkeypatch
21+
) -> None:
22+
catalog = {
23+
"categories": [{"slug": "tooling", "name": "Tooling"}],
24+
"templates": [
25+
{
26+
"slug": "fastapi-starter",
27+
"name": "FastAPI Starter",
28+
"url": "file:///templates/fastapi",
29+
"type": "fastapi-backend",
30+
"category": "backend-applications",
31+
}
32+
],
33+
"extensions": [
34+
{
35+
"slug": "github-setup",
36+
"name": "GitHub Setup",
37+
"url": "file:///extensions/github",
38+
"type": ["fastapi-backend"],
39+
"category": "tooling",
40+
}
41+
],
42+
}
43+
catalog_file = tmp_path / "templates.json"
44+
catalog_file.write_text(json.dumps(catalog), encoding="utf-8")
45+
monkeypatch.setenv("CPA_CATALOG_URL", f"file://{catalog_file}")
46+
monkeypatch.setenv("CPA_NO_CATALOG_CACHE", "1")
47+
48+
answers = [["tooling"], ["file:///extensions/github"]]
49+
50+
class FakePrompt:
51+
def __init__(self, answer):
52+
self.answer = answer
53+
54+
def ask(self):
55+
return self.answer
56+
57+
def fake_checkbox(*_args, **_kwargs):
58+
return FakePrompt(answers.pop(0))
59+
60+
captured: dict[str, object] = {}
61+
62+
async def fake_create_python_app(project_directory, options, *_args, **_kwargs):
63+
captured["project_directory"] = project_directory
64+
captured["options"] = options
65+
66+
async def fake_check_for_latest_version(_package_name):
67+
return None
68+
69+
monkeypatch.setattr("questionary.checkbox", fake_checkbox)
70+
monkeypatch.setattr(
71+
"create_awesome_python_app.cli.create_python_app",
72+
fake_create_python_app,
73+
)
74+
monkeypatch.setattr(
75+
"create_awesome_python_app.cli.check_for_latest_version",
76+
fake_check_for_latest_version,
77+
)
78+
79+
result = runner.invoke(
80+
app,
81+
[
82+
"--template",
83+
"fastapi-starter",
84+
"--interactive",
85+
"--no-install",
86+
"api",
87+
],
88+
)
89+
90+
assert result.exit_code == 0, result.stdout + result.stderr
91+
assert captured["project_directory"] == "api"
92+
options = captured["options"]
93+
assert isinstance(options, dict)
94+
assert options["addons"] == ["file:///extensions/github"]

0 commit comments

Comments
 (0)