Skip to content

Commit fafc4d5

Browse files
Merge pull request #239 from Create-Python-App/feat/issue-234-category-metadata
feat(cli): surface rich catalog category metadata
2 parents 628c41a + b0a92fe commit fafc4d5

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:
@@ -527,44 +574,96 @@ def reset_catalog_cache_for_tests() -> None:
527574

528575
def list_templates() -> None:
529576
data = get_catalog_data()
530-
table = Table(title="Templates")
531-
table.add_column("slug")
532-
table.add_column("category")
533-
table.add_column("type")
534-
for t in data.get("templates", []):
535-
table.add_row(
536-
str(t.get("slug", "")),
537-
str(t.get("category", "")),
538-
str(t.get("type", "")),
539-
)
540-
console.print(table)
577+
categories = category_index(data)
578+
templates = [t for t in data.get("templates", []) if isinstance(t, dict)]
579+
580+
console.print("[bold blue]\nAvailable Templates:[/bold blue]")
581+
# Preserve catalog category order, then any unknown slugs.
582+
ordered_slugs = list(categories)
583+
seen: set[str] = set()
584+
for slug in ordered_slugs:
585+
seen.add(slug)
586+
group = [t for t in templates if str(t.get("category", "")) == slug]
587+
if not group:
588+
continue
589+
info = categories[slug]
590+
console.print(f"[bold green]\n{info.name}:[/bold green]")
591+
if info.description:
592+
console.print(f" {info.description}")
593+
if info.details:
594+
console.print(f" [dim]{info.details}[/dim]")
595+
if info.labels:
596+
console.print(f" [dim]Keywords: {', '.join(info.labels)}[/dim]")
597+
for template in group:
598+
name = str(template.get("name", template.get("slug", "")))
599+
tslug = str(template.get("slug", ""))
600+
console.print(f" [yellow]{name}[/yellow] ([cyan]{tslug}[/cyan])")
601+
desc = str(template.get("description", "")).strip()
602+
if desc:
603+
console.print(f" {desc}")
604+
labels = template.get("labels", [])
605+
if isinstance(labels, list) and labels:
606+
console.print(f" Keywords: {', '.join(str(x) for x in labels)}")
607+
608+
orphans = [t for t in templates if str(t.get("category", "")) not in seen]
609+
if orphans:
610+
console.print("[bold green]\nOther:[/bold green]")
611+
for template in orphans:
612+
name = str(template.get("name", template.get("slug", "")))
613+
tslug = str(template.get("slug", ""))
614+
console.print(f" [yellow]{name}[/yellow] ([cyan]{tslug}[/cyan])")
541615

542616

543617
def list_addons(template_slug: str | None = None) -> None:
544618
data = get_catalog_data()
619+
categories = category_index(data)
545620
template_type: str | None = None
546621
if template_slug:
547622
for t in data.get("templates", []):
548-
if t.get("slug") == template_slug:
623+
if isinstance(t, dict) and t.get("slug") == template_slug:
549624
template_type = str(t.get("type", ""))
550625
break
551626

552-
table = Table(title="Extensions")
553-
table.add_column("slug")
554-
table.add_column("category")
555-
table.add_column("type")
556-
for ext in data.get("extensions", data.get("addons", [])):
627+
console.print("[bold blue]\nAvailable Addons:[/bold blue]")
628+
if template_slug:
629+
console.print(
630+
f"[bold green]\nCompatible with template: {template_slug}[/bold green]"
631+
)
632+
633+
extensions = [
634+
ext
635+
for ext in data.get("extensions", data.get("addons", []))
636+
if isinstance(ext, dict)
637+
]
638+
grouped: dict[str, list[dict[str, Any]]] = {}
639+
for ext in extensions:
557640
ext_types = ext.get("type", [])
558641
if isinstance(ext_types, str):
559642
ext_types = [ext_types]
560643
if template_type and template_type not in ext_types:
561644
continue
562-
type_label = (
563-
", ".join(ext_types) if isinstance(ext_types, list) else str(ext_types)
564-
)
565-
table.add_row(
566-
str(ext.get("slug", "")),
567-
str(ext.get("category", "")),
568-
type_label,
569-
)
570-
console.print(table)
645+
slug = str(ext.get("category", "custom"))
646+
grouped.setdefault(slug, []).append(ext)
647+
648+
for slug in list(categories) + [s for s in grouped if s not in categories]:
649+
group = grouped.get(slug)
650+
if not group:
651+
continue
652+
info = categories.get(slug) or CategoryInfo(slug=slug, name=slug)
653+
console.print(f"[bold green]\n{info.name}:[/bold green]")
654+
if info.description:
655+
console.print(f" {info.description}")
656+
if info.details:
657+
console.print(f" [dim]{info.details}[/dim]")
658+
if info.labels:
659+
console.print(f" [dim]Keywords: {', '.join(info.labels)}[/dim]")
660+
for ext in group:
661+
name = str(ext.get("name", ext.get("slug", "")))
662+
eslug = str(ext.get("slug", ""))
663+
console.print(f" [yellow]{name}[/yellow] ([cyan]{eslug}[/cyan])")
664+
desc = str(ext.get("description", "")).strip()
665+
if desc:
666+
console.print(f" {desc}")
667+
labels = ext.get("labels", [])
668+
if isinstance(labels, list) and labels:
669+
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
@@ -368,12 +368,22 @@ def scaffold(
368368
extension_choices = build_extension_choices(interactive_catalog, template)
369369
grouped_extensions = group_extension_choices(extension_choices)
370370
if grouped_extensions:
371+
from create_awesome_python_app.catalog import (
372+
CategoryInfo,
373+
category_index,
374+
format_category_choice_title,
375+
)
376+
377+
categories = category_index(interactive_catalog)
371378
category_choices = [
372379
Choice(
373-
title=(
374-
f"{choices[0].category_name} "
375-
f"({len(choices)} extension"
376-
f"{'s' if len(choices) != 1 else ''})"
380+
title=format_category_choice_title(
381+
categories.get(category_slug)
382+
or CategoryInfo(
383+
slug=category_slug,
384+
name=choices[0].category_name,
385+
),
386+
len(choices),
377387
),
378388
value=category_slug,
379389
)

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)