Skip to content

Commit b0a92fe

Browse files
feat(cli): surface rich catalog category metadata
Parse description/details/labels on categories, show them in --list-templates/--list-addons, and include them in the extension category picker (CNA parity). Closes #234 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 6012c89 commit b0a92fe

4 files changed

Lines changed: 182 additions & 36 deletions

File tree

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

Lines changed: 130 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313

1414
from create_python_app_core.paths import default_cache_dir, resolve_source
1515
from rich.console import Console
16-
from rich.table import Table
1716

1817
from create_awesome_python_app import __version__
1918
from create_awesome_python_app.prompt_style import (
@@ -159,6 +158,17 @@ def validate_extension_compatibility(
159158
raise IncompatibleExtensionsError(pairs)
160159

161160

161+
@dataclass(frozen=True)
162+
class CategoryInfo:
163+
"""Catalog category with optional rich metadata (CNA parity)."""
164+
165+
slug: str
166+
name: str
167+
description: str = ""
168+
details: str = ""
169+
labels: tuple[str, ...] = ()
170+
171+
162172
def short_category_label(category_name: str) -> str:
163173
"""Derive a compact badge label from a catalog category name."""
164174
stop_words = {"Applications", "Application", "Boilerplate"}
@@ -168,11 +178,48 @@ def short_category_label(category_name: str) -> str:
168178
return " ".join(words[:2]) or category_name
169179

170180

181+
def category_index(data: dict[str, Any]) -> dict[str, CategoryInfo]:
182+
"""Index categories by slug, including description/details/labels."""
183+
out: dict[str, CategoryInfo] = {}
184+
for raw in data.get("categories", []):
185+
if not isinstance(raw, dict):
186+
continue
187+
slug = str(raw.get("slug", "")).strip()
188+
if not slug:
189+
continue
190+
labels_raw = raw.get("labels", [])
191+
labels = (
192+
tuple(str(label) for label in labels_raw)
193+
if isinstance(labels_raw, list)
194+
else ()
195+
)
196+
out[slug] = CategoryInfo(
197+
slug=slug,
198+
name=str(raw.get("name", slug)),
199+
description=str(raw.get("description", "")).strip(),
200+
details=str(raw.get("details", "")).strip(),
201+
labels=labels,
202+
)
203+
return out
204+
205+
206+
def get_category_data(data: dict[str, Any], slug: str) -> CategoryInfo | None:
207+
return category_index(data).get(slug)
208+
209+
210+
def format_category_choice_title(info: CategoryInfo, extension_count: int) -> str:
211+
"""Human title for the interactive extension-category picker."""
212+
noun = "extension" if extension_count == 1 else "extensions"
213+
title = f"{info.name} ({extension_count} {noun})"
214+
if info.description:
215+
title = f"{title}{info.description}"
216+
if info.labels:
217+
title = f"{title} · {', '.join(info.labels[:3])}"
218+
return title
219+
220+
171221
def _category_map(data: dict[str, Any]) -> dict[str, str]:
172-
return {
173-
str(category.get("slug", "")): str(category.get("name", ""))
174-
for category in data.get("categories", [])
175-
}
222+
return {slug: info.name for slug, info in category_index(data).items()}
176223

177224

178225
def _search_text(template: dict[str, Any], category_name: str) -> str:
@@ -471,44 +518,96 @@ def reset_catalog_cache_for_tests() -> None:
471518

472519
def list_templates() -> None:
473520
data = get_catalog_data()
474-
table = Table(title="Templates")
475-
table.add_column("slug")
476-
table.add_column("category")
477-
table.add_column("type")
478-
for t in data.get("templates", []):
479-
table.add_row(
480-
str(t.get("slug", "")),
481-
str(t.get("category", "")),
482-
str(t.get("type", "")),
483-
)
484-
console.print(table)
521+
categories = category_index(data)
522+
templates = [t for t in data.get("templates", []) if isinstance(t, dict)]
523+
524+
console.print("[bold blue]\nAvailable Templates:[/bold blue]")
525+
# Preserve catalog category order, then any unknown slugs.
526+
ordered_slugs = list(categories)
527+
seen: set[str] = set()
528+
for slug in ordered_slugs:
529+
seen.add(slug)
530+
group = [t for t in templates if str(t.get("category", "")) == slug]
531+
if not group:
532+
continue
533+
info = categories[slug]
534+
console.print(f"[bold green]\n{info.name}:[/bold green]")
535+
if info.description:
536+
console.print(f" {info.description}")
537+
if info.details:
538+
console.print(f" [dim]{info.details}[/dim]")
539+
if info.labels:
540+
console.print(f" [dim]Keywords: {', '.join(info.labels)}[/dim]")
541+
for template in group:
542+
name = str(template.get("name", template.get("slug", "")))
543+
tslug = str(template.get("slug", ""))
544+
console.print(f" [yellow]{name}[/yellow] ([cyan]{tslug}[/cyan])")
545+
desc = str(template.get("description", "")).strip()
546+
if desc:
547+
console.print(f" {desc}")
548+
labels = template.get("labels", [])
549+
if isinstance(labels, list) and labels:
550+
console.print(f" Keywords: {', '.join(str(x) for x in labels)}")
551+
552+
orphans = [t for t in templates if str(t.get("category", "")) not in seen]
553+
if orphans:
554+
console.print("[bold green]\nOther:[/bold green]")
555+
for template in orphans:
556+
name = str(template.get("name", template.get("slug", "")))
557+
tslug = str(template.get("slug", ""))
558+
console.print(f" [yellow]{name}[/yellow] ([cyan]{tslug}[/cyan])")
485559

486560

487561
def list_addons(template_slug: str | None = None) -> None:
488562
data = get_catalog_data()
563+
categories = category_index(data)
489564
template_type: str | None = None
490565
if template_slug:
491566
for t in data.get("templates", []):
492-
if t.get("slug") == template_slug:
567+
if isinstance(t, dict) and t.get("slug") == template_slug:
493568
template_type = str(t.get("type", ""))
494569
break
495570

496-
table = Table(title="Extensions")
497-
table.add_column("slug")
498-
table.add_column("category")
499-
table.add_column("type")
500-
for ext in data.get("extensions", data.get("addons", [])):
571+
console.print("[bold blue]\nAvailable Addons:[/bold blue]")
572+
if template_slug:
573+
console.print(
574+
f"[bold green]\nCompatible with template: {template_slug}[/bold green]"
575+
)
576+
577+
extensions = [
578+
ext
579+
for ext in data.get("extensions", data.get("addons", []))
580+
if isinstance(ext, dict)
581+
]
582+
grouped: dict[str, list[dict[str, Any]]] = {}
583+
for ext in extensions:
501584
ext_types = ext.get("type", [])
502585
if isinstance(ext_types, str):
503586
ext_types = [ext_types]
504587
if template_type and template_type not in ext_types:
505588
continue
506-
type_label = (
507-
", ".join(ext_types) if isinstance(ext_types, list) else str(ext_types)
508-
)
509-
table.add_row(
510-
str(ext.get("slug", "")),
511-
str(ext.get("category", "")),
512-
type_label,
513-
)
514-
console.print(table)
589+
slug = str(ext.get("category", "custom"))
590+
grouped.setdefault(slug, []).append(ext)
591+
592+
for slug in list(categories) + [s for s in grouped if s not in categories]:
593+
group = grouped.get(slug)
594+
if not group:
595+
continue
596+
info = categories.get(slug) or CategoryInfo(slug=slug, name=slug)
597+
console.print(f"[bold green]\n{info.name}:[/bold green]")
598+
if info.description:
599+
console.print(f" {info.description}")
600+
if info.details:
601+
console.print(f" [dim]{info.details}[/dim]")
602+
if info.labels:
603+
console.print(f" [dim]Keywords: {', '.join(info.labels)}[/dim]")
604+
for ext in group:
605+
name = str(ext.get("name", ext.get("slug", "")))
606+
eslug = str(ext.get("slug", ""))
607+
console.print(f" [yellow]{name}[/yellow] ([cyan]{eslug}[/cyan])")
608+
desc = str(ext.get("description", "")).strip()
609+
if desc:
610+
console.print(f" {desc}")
611+
labels = ext.get("labels", [])
612+
if isinstance(labels, list) and labels:
613+
console.print(f" Keywords: {', '.join(str(x) for x in labels)}")

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

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -315,12 +315,22 @@ def scaffold(
315315
extension_choices = build_extension_choices(interactive_catalog, template)
316316
grouped_extensions = group_extension_choices(extension_choices)
317317
if grouped_extensions:
318+
from create_awesome_python_app.catalog import (
319+
CategoryInfo,
320+
category_index,
321+
format_category_choice_title,
322+
)
323+
324+
categories = category_index(interactive_catalog)
318325
category_choices = [
319326
Choice(
320-
title=(
321-
f"{choices[0].category_name} "
322-
f"({len(choices)} extension"
323-
f"{'s' if len(choices) != 1 else ''})"
327+
title=format_category_choice_title(
328+
categories.get(category_slug)
329+
or CategoryInfo(
330+
slug=category_slug,
331+
name=choices[0].category_name,
332+
),
333+
len(choices),
324334
),
325335
value=category_slug,
326336
)

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,18 @@
44
runner = CliRunner()
55

66

7+
def _out(result) -> str:
8+
return (result.stdout or "") + (result.stderr or "")
9+
10+
711
def test_list_templates() -> None:
812
result = runner.invoke(app, ["--list-templates"])
913
assert result.exit_code == 0
14+
text = _out(result)
15+
assert "Available Templates" in text
1016

1117

1218
def test_list_addons() -> None:
13-
assert runner.invoke(app, ["--list-addons"]).exit_code == 0
19+
result = runner.invoke(app, ["--list-addons"])
20+
assert result.exit_code == 0
21+
assert "Available Addons" in _out(result)

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,3 +307,32 @@ def test_find_incompatible_pairs_dedupes_symmetric_edges() -> None:
307307
}
308308
pairs = find_incompatible_pairs(["a", "b"], catalog=catalog)
309309
assert pairs == [("a", "b")]
310+
311+
312+
def test_category_index_includes_rich_metadata() -> None:
313+
from create_awesome_python_app.catalog import (
314+
category_index,
315+
format_category_choice_title,
316+
)
317+
318+
catalog = {
319+
"categories": [
320+
{
321+
"slug": "backend-applications",
322+
"name": "Backend Applications",
323+
"description": "API starters",
324+
"details": "FastAPI and friends",
325+
"labels": ["Backend", "API"],
326+
}
327+
],
328+
"templates": [],
329+
"extensions": [],
330+
}
331+
info = category_index(catalog)["backend-applications"]
332+
assert info.description == "API starters"
333+
assert info.details == "FastAPI and friends"
334+
assert info.labels == ("Backend", "API")
335+
title = format_category_choice_title(info, 2)
336+
assert "Backend Applications (2 extensions)" in title
337+
assert "API starters" in title
338+
assert "Backend" in title

0 commit comments

Comments
 (0)