Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,7 +13,6 @@

from create_python_app_core.paths import default_cache_dir, resolve_source
from rich.console import Console
from rich.table import Table

from create_awesome_python_app import __version__
from create_awesome_python_app.prompt_style import (
Expand DownExpand Up@@ -159,6 +158,17 @@ def validate_extension_compatibility(
raise IncompatibleExtensionsError(pairs)


@dataclass(frozen=True)
class CategoryInfo:
"""Catalog category with optional rich metadata (CNA parity)."""

slug: str
name: str
description: str = ""
details: str = ""
labels: tuple[str, ...] = ()


def short_category_label(category_name: str) -> str:
"""Derive a compact badge label from a catalog category name."""
stop_words = {"Applications", "Application", "Boilerplate"}
Expand All@@ -168,11 +178,48 @@ def short_category_label(category_name: str) -> str:
return " ".join(words[:2]) or category_name


def category_index(data: dict[str, Any]) -> dict[str, CategoryInfo]:
"""Index categories by slug, including description/details/labels."""
out: dict[str, CategoryInfo] = {}
for raw in data.get("categories", []):
if not isinstance(raw, dict):
continue
slug = str(raw.get("slug", "")).strip()
if not slug:
continue
labels_raw = raw.get("labels", [])
labels = (
tuple(str(label) for label in labels_raw)
if isinstance(labels_raw, list)
else ()
)
out[slug] = CategoryInfo(
slug=slug,
name=str(raw.get("name", slug)),
description=str(raw.get("description", "")).strip(),
details=str(raw.get("details", "")).strip(),
labels=labels,
)
return out


def get_category_data(data: dict[str, Any], slug: str) -> CategoryInfo | None:
return category_index(data).get(slug)


def format_category_choice_title(info: CategoryInfo, extension_count: int) -> str:
"""Human title for the interactive extension-category picker."""
noun = "extension" if extension_count == 1 else "extensions"
title = f"{info.name} ({extension_count} {noun})"
if info.description:
title = f"{title} — {info.description}"
if info.labels:
title = f"{title} · {', '.join(info.labels[:3])}"
return title


def _category_map(data: dict[str, Any]) -> dict[str, str]:
return {
str(category.get("slug", "")): str(category.get("name", ""))
for category in data.get("categories", [])
}
return {slug: info.name for slug, info in category_index(data).items()}


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

def list_templates() -> None:
data = get_catalog_data()
table = Table(title="Templates")
table.add_column("slug")
table.add_column("category")
table.add_column("type")
for t in data.get("templates", []):
table.add_row(
str(t.get("slug", "")),
str(t.get("category", "")),
str(t.get("type", "")),
)
console.print(table)
categories = category_index(data)
templates = [t for t in data.get("templates", []) if isinstance(t, dict)]

console.print("[bold blue]\nAvailable Templates:[/bold blue]")
# Preserve catalog category order, then any unknown slugs.
ordered_slugs = list(categories)
seen: set[str] = set()
for slug in ordered_slugs:
seen.add(slug)
group = [t for t in templates if str(t.get("category", "")) == slug]
if not group:
continue
info = categories[slug]
console.print(f"[bold green]\n{info.name}:[/bold green]")
if info.description:
console.print(f" {info.description}")
if info.details:
console.print(f" [dim]{info.details}[/dim]")
if info.labels:
console.print(f" [dim]Keywords: {', '.join(info.labels)}[/dim]")
for template in group:
name = str(template.get("name", template.get("slug", "")))
tslug = str(template.get("slug", ""))
console.print(f" [yellow]{name}[/yellow] ([cyan]{tslug}[/cyan])")
desc = str(template.get("description", "")).strip()
if desc:
console.print(f" {desc}")
labels = template.get("labels", [])
if isinstance(labels, list) and labels:
console.print(f" Keywords: {', '.join(str(x) for x in labels)}")

orphans = [t for t in templates if str(t.get("category", "")) not in seen]
if orphans:
console.print("[bold green]\nOther:[/bold green]")
for template in orphans:
name = str(template.get("name", template.get("slug", "")))
tslug = str(template.get("slug", ""))
console.print(f" [yellow]{name}[/yellow] ([cyan]{tslug}[/cyan])")


def list_addons(template_slug: str | None = None) -> None:
data = get_catalog_data()
categories = category_index(data)
template_type: str | None = None
if template_slug:
for t in data.get("templates", []):
if t.get("slug") == template_slug:
if isinstance(t, dict) and t.get("slug") == template_slug:
template_type = str(t.get("type", ""))
break

table = Table(title="Extensions")
table.add_column("slug")
table.add_column("category")
table.add_column("type")
for ext in data.get("extensions", data.get("addons", [])):
console.print("[bold blue]\nAvailable Addons:[/bold blue]")
if template_slug:
console.print(
f"[bold green]\nCompatible with template: {template_slug}[/bold green]"
)

extensions = [
ext
for ext in data.get("extensions", data.get("addons", []))
if isinstance(ext, dict)
]
grouped: dict[str, list[dict[str, Any]]] = {}
for ext in extensions:
ext_types = ext.get("type", [])
if isinstance(ext_types, str):
ext_types = [ext_types]
if template_type and template_type not in ext_types:
continue
type_label = (
", ".join(ext_types) if isinstance(ext_types, list) else str(ext_types)
)
table.add_row(
str(ext.get("slug", "")),
str(ext.get("category", "")),
type_label,
)
console.print(table)
slug = str(ext.get("category", "custom"))
grouped.setdefault(slug, []).append(ext)

for slug in list(categories) + [s for s in grouped if s not in categories]:
group = grouped.get(slug)
if not group:
continue
info = categories.get(slug) or CategoryInfo(slug=slug, name=slug)
console.print(f"[bold green]\n{info.name}:[/bold green]")
if info.description:
console.print(f" {info.description}")
if info.details:
console.print(f" [dim]{info.details}[/dim]")
if info.labels:
console.print(f" [dim]Keywords: {', '.join(info.labels)}[/dim]")
for ext in group:
name = str(ext.get("name", ext.get("slug", "")))
eslug = str(ext.get("slug", ""))
console.print(f" [yellow]{name}[/yellow] ([cyan]{eslug}[/cyan])")
desc = str(ext.get("description", "")).strip()
if desc:
console.print(f" {desc}")
labels = ext.get("labels", [])
if isinstance(labels, list) and labels:
console.print(f" Keywords: {', '.join(str(x) for x in labels)}")
Original file line numberDiff line numberDiff line change
Expand Up@@ -315,12 +315,22 @@ def scaffold(
extension_choices = build_extension_choices(interactive_catalog, template)
grouped_extensions = group_extension_choices(extension_choices)
if grouped_extensions:
from create_awesome_python_app.catalog import (
CategoryInfo,
category_index,
format_category_choice_title,
)

categories = category_index(interactive_catalog)
category_choices = [
Choice(
title=(
f"{choices[0].category_name} "
f"({len(choices)} extension"
f"{'s' if len(choices) != 1 else ''})"
title=format_category_choice_title(
categories.get(category_slug)
or CategoryInfo(
slug=category_slug,
name=choices[0].category_name,
),
len(choices),
),
value=category_slug,
)
Expand Down
10 changes: 9 additions & 1 deletion packages/create-awesome-python-app/tests/test_catalog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,10 +4,18 @@
runner = CliRunner()


def _out(result) -> str:
return (result.stdout or "") + (result.stderr or "")


def test_list_templates() -> None:
result = runner.invoke(app, ["--list-templates"])
assert result.exit_code == 0
text = _out(result)
assert "Available Templates" in text


def test_list_addons() -> None:
assert runner.invoke(app, ["--list-addons"]).exit_code == 0
result = runner.invoke(app, ["--list-addons"])
assert result.exit_code == 0
assert "Available Addons" in _out(result)
29 changes: 29 additions & 0 deletions packages/create-awesome-python-app/tests/test_catalog_resolve.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -307,3 +307,32 @@ def test_find_incompatible_pairs_dedupes_symmetric_edges() -> None:
}
pairs = find_incompatible_pairs(["a", "b"], catalog=catalog)
assert pairs == [("a", "b")]


def test_category_index_includes_rich_metadata() -> None:
from create_awesome_python_app.catalog import (
category_index,
format_category_choice_title,
)

catalog = {
"categories": [
{
"slug": "backend-applications",
"name": "Backend Applications",
"description": "API starters",
"details": "FastAPI and friends",
"labels": ["Backend", "API"],
}
],
"templates": [],
"extensions": [],
}
info = category_index(catalog)["backend-applications"]
assert info.description == "API starters"
assert info.details == "FastAPI and friends"
assert info.labels == ("Backend", "API")
title = format_category_choice_title(info, 2)
assert "Backend Applications (2 extensions)" in title
assert "API starters" in title
assert "Backend" in title
Loading