From 9cc56178f9724cb183b014212b76a5950485c8d7 Mon Sep 17 00:00:00 2001 From: ulises-jeremias Date: Thu, 16 Jul 2026 13:14:45 -0300 Subject: [PATCH 1/2] feat(cli): add searchable template wizard Co-authored-by: Cursor --- .../src/create_awesome_python_app/catalog.py | 110 ++++++++++++++++++ .../src/create_awesome_python_app/cli.py | 29 ++++- .../tests/test_catalog_resolve.py | 40 +++++++ 3 files changed, 177 insertions(+), 2 deletions(-) diff --git a/packages/create-awesome-python-app/src/create_awesome_python_app/catalog.py b/packages/create-awesome-python-app/src/create_awesome_python_app/catalog.py index d2134b1..19a5947 100644 --- a/packages/create-awesome-python-app/src/create_awesome_python_app/catalog.py +++ b/packages/create-awesome-python-app/src/create_awesome_python_app/catalog.py @@ -7,6 +7,7 @@ import time import urllib.error import urllib.request +from dataclasses import dataclass from pathlib import Path from typing import Any @@ -18,6 +19,25 @@ console = Console(stderr=True) +CUSTOM_TEMPLATE_SENTINEL = "__custom_template__" +_ANSI_RESET = "\033[0m" +_CATEGORY_PALETTE = ( + "\033[33m", # yellow + "\033[32m", # green + "\033[36m", # cyan + "\033[35m", # magenta + "\033[34m", # blue +) + + +@dataclass(frozen=True) +class TemplateChoice: + """Searchable interactive template choice.""" + + title: str + value: str + search: str + class CatalogResolutionError(ValueError): """Raised when a template or extension slug is not in the catalog.""" @@ -55,6 +75,96 @@ def resolve_catalog_specs( return [resolve_catalog_spec(spec, catalog=catalog) for spec in specs] +def short_category_label(category_name: str) -> str: + """Derive a compact badge label from a catalog category name.""" + stop_words = {"Applications", "Application", "Boilerplate"} + words = [word for word in category_name.split() if word not in stop_words] + if len(words) >= 3: + return "".join(word[:1].upper() for word in words) + return " ".join(words[:2]) or category_name + + +def _color_category(slug: str, label: str) -> str: + if os.environ.get("NO_COLOR"): + return label + idx = sum(ord(char) for char in slug) % len(_CATEGORY_PALETTE) + return f"{_CATEGORY_PALETTE[idx]}{label}{_ANSI_RESET}" + + +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", []) + } + + +def _search_text(template: dict[str, Any], category_name: str) -> str: + labels = template.get("labels", []) + if not isinstance(labels, list): + labels = [] + tokens = [ + template.get("slug", ""), + template.get("name", ""), + template.get("description", ""), + template.get("category", ""), + category_name, + *labels, + ] + return " ".join(str(token) for token in tokens if token).lower() + + +def build_template_choices(data: dict[str, Any]) -> list[TemplateChoice]: + """Build CNA-style searchable template choices for interactive mode.""" + categories = _category_map(data) + choices: list[TemplateChoice] = [] + templates = sorted( + (item for item in data.get("templates", []) if isinstance(item, dict)), + key=lambda item: ( + list(categories).index(str(item.get("category", ""))) + if str(item.get("category", "")) in categories + else len(categories), + str(item.get("name", item.get("slug", ""))).lower(), + ), + ) + for template in templates: + if not isinstance(template, dict): + continue + template_url = str(template.get("url", "")) + if not template_url: + continue + category_slug = str(template.get("category", "custom")) + category_name = categories.get(category_slug, category_slug) + badge = short_category_label(category_name).ljust(10)[:10] + slug = str(template.get("slug", "")) + labels = template.get("labels", []) + label_suffix = "" + if isinstance(labels, list) and labels: + label_suffix = " · " + ", ".join(str(label) for label in labels[:3]) + description = str(template.get("description", "")).strip() + description_suffix = f" — {description}" if description else "" + title = ( + f"{_color_category(category_slug, badge)} " + f"{template.get('name', slug)} ({slug})" + f"{label_suffix}{description_suffix}" + ) + choices.append( + TemplateChoice( + title=title, + value=template_url, + search=_search_text(template, category_name), + ) + ) + + choices.append( + TemplateChoice( + title=" " * 12 + "Use my own template URL", + value=CUSTOM_TEMPLATE_SENTINEL, + search="custom own template url github file", + ) + ) + return choices + + DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/Create-Python-App/cpa-templates/main/templates.json" CACHE_TTL_SECONDS = 3600 FETCH_TIMEOUT_SECONDS = 10 diff --git a/packages/create-awesome-python-app/src/create_awesome_python_app/cli.py b/packages/create-awesome-python-app/src/create_awesome_python_app/cli.py index e7ba59a..96ae07d 100644 --- a/packages/create-awesome-python-app/src/create_awesome_python_app/cli.py +++ b/packages/create-awesome-python-app/src/create_awesome_python_app/cli.py @@ -108,9 +108,34 @@ def scaffold( try: import questionary - template = questionary.text( - "Template (slug or URL)", default="file://." + from create_awesome_python_app.catalog import ( + CUSTOM_TEMPLATE_SENTINEL, + build_template_choices, + get_catalog_data, + ) + + catalog = get_catalog_data() + template_choices = build_template_choices(catalog) + choice_by_title = { + choice.title: choice.value for choice in template_choices + } + selected_title = questionary.autocomplete( + "Pick a template (type to search)", + choices=list(choice_by_title), + match_middle=True, + qmark="?", + pointer=">", ).ask() + selected_template = choice_by_title.get( + str(selected_title), selected_title + ) + if selected_template == CUSTOM_TEMPLATE_SENTINEL: + selected_template = questionary.text( + "Template URL", + default="file://.", + validate=lambda value: bool(value) or "Template URL is required", + ).ask() + template = selected_template if not template: raise typer.Exit(1) except ImportError: diff --git a/packages/create-awesome-python-app/tests/test_catalog_resolve.py b/packages/create-awesome-python-app/tests/test_catalog_resolve.py index 0ae84e4..96cf17a 100644 --- a/packages/create-awesome-python-app/tests/test_catalog_resolve.py +++ b/packages/create-awesome-python-app/tests/test_catalog_resolve.py @@ -4,10 +4,13 @@ import pytest from create_awesome_python_app.catalog import ( + CUSTOM_TEMPLATE_SENTINEL, CatalogResolutionError, + build_template_choices, is_url_like, resolve_catalog_spec, resolve_catalog_specs, + short_category_label, ) SAMPLE_CATALOG = { @@ -61,3 +64,40 @@ def test_resolve_catalog_specs_batch() -> None: ) assert len(resolved) == 2 assert resolved[1] == "file:///ext" + + +def test_short_category_label_matches_cna_style() -> None: + assert short_category_label("Backend Applications") == "Backend" + assert short_category_label("User Acceptance Testing") == "UAT" + + +def test_build_template_choices_are_searchable() -> None: + catalog = { + "categories": [ + { + "slug": "backend-applications", + "name": "Backend Applications", + } + ], + "templates": [ + { + "slug": "fastapi-starter", + "name": "FastAPI Starter", + "description": "Async API with OpenAPI docs", + "url": "file:///templates/fastapi", + "category": "backend-applications", + "labels": ["FastAPI", "API", "uv"], + } + ], + } + + choices = build_template_choices(catalog) + first = choices[0] + assert first.value == "file:///templates/fastapi" + assert "FastAPI Starter" in first.title + assert "OpenAPI" in first.title + assert "uv" in first.title + assert "openapi" in first.search + assert "backend" in first.search + assert "uv" in first.search + assert choices[-1].value == CUSTOM_TEMPLATE_SENTINEL From 74007ed9ad02567d50f717cdf7d036629fcf22a5 Mon Sep 17 00:00:00 2001 From: ulises-jeremias Date: Thu, 16 Jul 2026 13:28:02 -0300 Subject: [PATCH 2/2] style(cli): format interactive template wizard Co-authored-by: Cursor --- .../src/create_awesome_python_app/cli.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/create-awesome-python-app/src/create_awesome_python_app/cli.py b/packages/create-awesome-python-app/src/create_awesome_python_app/cli.py index 96ae07d..54b4bc0 100644 --- a/packages/create-awesome-python-app/src/create_awesome_python_app/cli.py +++ b/packages/create-awesome-python-app/src/create_awesome_python_app/cli.py @@ -126,9 +126,7 @@ def scaffold( qmark="?", pointer=">", ).ask() - selected_template = choice_by_title.get( - str(selected_title), selected_title - ) + selected_template = choice_by_title.get(str(selected_title), selected_title) if selected_template == CUSTOM_TEMPLATE_SENTINEL: selected_template = questionary.text( "Template URL",