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@@ -7,6 +7,7 @@
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Any

Expand All@@ -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."""
Expand DownExpand Up@@ -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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -108,9 +108,32 @@ 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:
Expand Down
40 changes: 40 additions & 0 deletions packages/create-awesome-python-app/tests/test_catalog_resolve.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 = {
Expand DownExpand Up@@ -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
Loading