Skip to content

Commit 4abe752

Browse files
feat(cli): add searchable template wizard
Closes #162.
1 parent 5c95043 commit 4abe752

3 files changed

Lines changed: 175 additions & 2 deletions

File tree

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

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import time
88
import urllib.error
99
import urllib.request
10+
from dataclasses import dataclass
1011
from pathlib import Path
1112
from typing import Any
1213

@@ -18,6 +19,25 @@
1819

1920
console = Console(stderr=True)
2021

22+
CUSTOM_TEMPLATE_SENTINEL = "__custom_template__"
23+
_ANSI_RESET = "\033[0m"
24+
_CATEGORY_PALETTE = (
25+
"\033[33m", # yellow
26+
"\033[32m", # green
27+
"\033[36m", # cyan
28+
"\033[35m", # magenta
29+
"\033[34m", # blue
30+
)
31+
32+
33+
@dataclass(frozen=True)
34+
class TemplateChoice:
35+
"""Searchable interactive template choice."""
36+
37+
title: str
38+
value: str
39+
search: str
40+
2141

2242
class CatalogResolutionError(ValueError):
2343
"""Raised when a template or extension slug is not in the catalog."""
@@ -55,6 +75,96 @@ def resolve_catalog_specs(
5575
return [resolve_catalog_spec(spec, catalog=catalog) for spec in specs]
5676

5777

78+
def short_category_label(category_name: str) -> str:
79+
"""Derive a compact badge label from a catalog category name."""
80+
stop_words = {"Applications", "Application", "Boilerplate"}
81+
words = [word for word in category_name.split() if word not in stop_words]
82+
if len(words) >= 3:
83+
return "".join(word[:1].upper() for word in words)
84+
return " ".join(words[:2]) or category_name
85+
86+
87+
def _color_category(slug: str, label: str) -> str:
88+
if os.environ.get("NO_COLOR"):
89+
return label
90+
idx = sum(ord(char) for char in slug) % len(_CATEGORY_PALETTE)
91+
return f"{_CATEGORY_PALETTE[idx]}{label}{_ANSI_RESET}"
92+
93+
94+
def _category_map(data: dict[str, Any]) -> dict[str, str]:
95+
return {
96+
str(category.get("slug", "")): str(category.get("name", ""))
97+
for category in data.get("categories", [])
98+
}
99+
100+
101+
def _search_text(template: dict[str, Any], category_name: str) -> str:
102+
labels = template.get("labels", [])
103+
if not isinstance(labels, list):
104+
labels = []
105+
tokens = [
106+
template.get("slug", ""),
107+
template.get("name", ""),
108+
template.get("description", ""),
109+
template.get("category", ""),
110+
category_name,
111+
*labels,
112+
]
113+
return " ".join(str(token) for token in tokens if token).lower()
114+
115+
116+
def build_template_choices(data: dict[str, Any]) -> list[TemplateChoice]:
117+
"""Build CNA-style searchable template choices for interactive mode."""
118+
categories = _category_map(data)
119+
choices: list[TemplateChoice] = []
120+
templates = sorted(
121+
(item for item in data.get("templates", []) if isinstance(item, dict)),
122+
key=lambda item: (
123+
list(categories).index(str(item.get("category", "")))
124+
if str(item.get("category", "")) in categories
125+
else len(categories),
126+
str(item.get("name", item.get("slug", ""))).lower(),
127+
),
128+
)
129+
for template in templates:
130+
if not isinstance(template, dict):
131+
continue
132+
template_url = str(template.get("url", ""))
133+
if not template_url:
134+
continue
135+
category_slug = str(template.get("category", "custom"))
136+
category_name = categories.get(category_slug, category_slug)
137+
badge = short_category_label(category_name).ljust(10)[:10]
138+
slug = str(template.get("slug", ""))
139+
labels = template.get("labels", [])
140+
label_suffix = ""
141+
if isinstance(labels, list) and labels:
142+
label_suffix = " · " + ", ".join(str(label) for label in labels[:3])
143+
description = str(template.get("description", "")).strip()
144+
description_suffix = f" — {description}" if description else ""
145+
title = (
146+
f"{_color_category(category_slug, badge)} "
147+
f"{template.get('name', slug)} ({slug})"
148+
f"{label_suffix}{description_suffix}"
149+
)
150+
choices.append(
151+
TemplateChoice(
152+
title=title,
153+
value=template_url,
154+
search=_search_text(template, category_name),
155+
)
156+
)
157+
158+
choices.append(
159+
TemplateChoice(
160+
title=" " * 12 + "Use my own template URL",
161+
value=CUSTOM_TEMPLATE_SENTINEL,
162+
search="custom own template url github file",
163+
)
164+
)
165+
return choices
166+
167+
58168
DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/Create-Python-App/cpa-templates/main/templates.json"
59169
CACHE_TTL_SECONDS = 3600
60170
FETCH_TIMEOUT_SECONDS = 10

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

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,32 @@ def scaffold(
108108
try:
109109
import questionary
110110

111-
template = questionary.text(
112-
"Template (slug or URL)", default="file://."
111+
from create_awesome_python_app.catalog import (
112+
CUSTOM_TEMPLATE_SENTINEL,
113+
build_template_choices,
114+
get_catalog_data,
115+
)
116+
117+
catalog = get_catalog_data()
118+
template_choices = build_template_choices(catalog)
119+
choice_by_title = {
120+
choice.title: choice.value for choice in template_choices
121+
}
122+
selected_title = questionary.autocomplete(
123+
"Pick a template (type to search)",
124+
choices=list(choice_by_title),
125+
match_middle=True,
126+
qmark="?",
127+
pointer=">",
113128
).ask()
129+
selected_template = choice_by_title.get(str(selected_title), selected_title)
130+
if selected_template == CUSTOM_TEMPLATE_SENTINEL:
131+
selected_template = questionary.text(
132+
"Template URL",
133+
default="file://.",
134+
validate=lambda value: bool(value) or "Template URL is required",
135+
).ask()
136+
template = selected_template
114137
if not template:
115138
raise typer.Exit(1)
116139
except ImportError:

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@
44

55
import pytest
66
from create_awesome_python_app.catalog import (
7+
CUSTOM_TEMPLATE_SENTINEL,
78
CatalogResolutionError,
9+
build_template_choices,
810
is_url_like,
911
resolve_catalog_spec,
1012
resolve_catalog_specs,
13+
short_category_label,
1114
)
1215

1316
SAMPLE_CATALOG = {
@@ -61,3 +64,40 @@ def test_resolve_catalog_specs_batch() -> None:
6164
)
6265
assert len(resolved) == 2
6366
assert resolved[1] == "file:///ext"
67+
68+
69+
def test_short_category_label_matches_cna_style() -> None:
70+
assert short_category_label("Backend Applications") == "Backend"
71+
assert short_category_label("User Acceptance Testing") == "UAT"
72+
73+
74+
def test_build_template_choices_are_searchable() -> None:
75+
catalog = {
76+
"categories": [
77+
{
78+
"slug": "backend-applications",
79+
"name": "Backend Applications",
80+
}
81+
],
82+
"templates": [
83+
{
84+
"slug": "fastapi-starter",
85+
"name": "FastAPI Starter",
86+
"description": "Async API with OpenAPI docs",
87+
"url": "file:///templates/fastapi",
88+
"category": "backend-applications",
89+
"labels": ["FastAPI", "API", "uv"],
90+
}
91+
],
92+
}
93+
94+
choices = build_template_choices(catalog)
95+
first = choices[0]
96+
assert first.value == "file:///templates/fastapi"
97+
assert "FastAPI Starter" in first.title
98+
assert "OpenAPI" in first.title
99+
assert "uv" in first.title
100+
assert "openapi" in first.search
101+
assert "backend" in first.search
102+
assert "uv" in first.search
103+
assert choices[-1].value == CUSTOM_TEMPLATE_SENTINEL

0 commit comments

Comments
 (0)