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@@ -39,6 +39,18 @@ class TemplateChoice:
search: str


@dataclass(frozen=True)
class ExtensionChoice:
"""Interactive extension choice grouped by catalog category."""

title: str
value: str
search: str
category_slug: str
category_name: str
category_order: int


class CatalogResolutionError(ValueError):
"""Raised when a template or extension slug is not in the catalog."""

Expand DownExpand Up@@ -113,6 +125,32 @@ def _search_text(template: dict[str, Any], category_name: str) -> str:
return " ".join(str(token) for token in tokens if token).lower()


def _catalog_category_order(data: dict[str, Any]) -> dict[str, int]:
return {
str(category.get("slug", "")): index
for index, category in enumerate(data.get("categories", []))
}


def _entry_type_values(entry: dict[str, Any]) -> list[str]:
raw_type = entry.get("type", [])
if isinstance(raw_type, str):
return [raw_type]
if isinstance(raw_type, list):
return [str(item) for item in raw_type]
return []


def find_template_by_url(
data: dict[str, Any], template_url: str
) -> dict[str, Any] | None:
"""Return the catalog template entry for a resolved template URL."""
for template in data.get("templates", []):
if isinstance(template, dict) and template.get("url") == template_url:
return template
return None


def build_template_choices(data: dict[str, Any]) -> list[TemplateChoice]:
"""Build CNA-style searchable template choices for interactive mode."""
categories = _category_map(data)
Expand DownExpand Up@@ -165,6 +203,67 @@ def build_template_choices(data: dict[str, Any]) -> list[TemplateChoice]:
return choices


def build_extension_choices(
data: dict[str, Any], template_url: str
) -> list[ExtensionChoice]:
"""Build CNA-style extension choices compatible with the selected template."""
categories = _category_map(data)
category_order = _catalog_category_order(data)
template = find_template_by_url(data, template_url)
template_types = _entry_type_values(template or {})
if not template_types:
template_types = ["custom"]

choices: list[ExtensionChoice] = []
for extension in data.get("extensions", data.get("addons", [])):
if not isinstance(extension, dict):
continue
extension_types = _entry_type_values(extension)
if not any(
ext_type in template_types or ext_type == "all"
for ext_type in extension_types
):
continue
extension_url = str(extension.get("url", ""))
if not extension_url:
continue
category_slug = str(extension.get("category", "custom"))
category_name = categories.get(category_slug, category_slug)
labels = extension.get("labels", [])
label_suffix = ""
if isinstance(labels, list) and labels:
label_suffix = " · " + ", ".join(str(label) for label in labels[:3])
description = str(extension.get("description", "")).strip()
description_suffix = f" — {description}" if description else ""
slug = str(extension.get("slug", ""))
title = f"{extension.get('name', slug)} ({slug}){label_suffix}"
choices.append(
ExtensionChoice(
title=f"{title}{description_suffix}",
value=extension_url,
search=_search_text(extension, category_name),
category_slug=category_slug,
category_name=category_name,
category_order=category_order.get(category_slug, len(category_order)),
)
)

return sorted(
choices,
key=lambda choice: (choice.category_order, choice.title.lower()),
)


def group_extension_choices(
choices: list[ExtensionChoice],
) -> dict[str, list[ExtensionChoice]]:
"""Group extension choices by category while preserving sorted order."""
grouped: dict[str, list[ExtensionChoice]] = {}
for choice in choices:
grouped.setdefault(choice.category_slug, []).append(choice)
return grouped


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@@ -104,6 +104,7 @@ def scaffold(
pass # passed to core

want_interactive = interactive if interactive is not None else (not _in_ci())
interactive_catalog: dict[str, object] | None = None
if want_interactive and not template:
try:
import questionary
Expand All@@ -114,8 +115,8 @@ def scaffold(
get_catalog_data,
)

catalog = get_catalog_data()
template_choices = build_template_choices(catalog)
interactive_catalog = get_catalog_data()
template_choices = build_template_choices(interactive_catalog)
choice_by_title = {
choice.title: choice.value for choice in template_choices
}
Expand DownExpand Up@@ -158,6 +159,58 @@ def scaffold(
console.print(f"[red]{err}[/red]")
raise typer.Exit(2) from err

if want_interactive and not addons:
try:
import questionary
from questionary import Choice

from create_awesome_python_app.catalog import (
build_extension_choices,
get_catalog_data,
group_extension_choices,
)

interactive_catalog = interactive_catalog or get_catalog_data()
extension_choices = build_extension_choices(interactive_catalog, template)
grouped_extensions = group_extension_choices(extension_choices)
if grouped_extensions:
category_choices = [
Choice(
title=(
f"{choices[0].category_name} "
f"({len(choices)} extension"
f"{'s' if len(choices) != 1 else ''})"
),
value=category_slug,
)
for category_slug, choices in grouped_extensions.items()
]
selected_categories = questionary.checkbox(
"Which kinds of extensions do you need?",
choices=category_choices,
qmark="?",
pointer=">",
).ask()
selected_addons: list[str] = []
for category_slug in selected_categories or []:
choices = grouped_extensions.get(str(category_slug), [])
if not choices:
continue
picked = questionary.checkbox(
f"{choices[0].category_name} extensions",
choices=[
Choice(title=choice.title, value=choice.value)
for choice in choices
],
qmark="?",
pointer=">",
).ask()
selected_addons.extend(str(item) for item in picked or [])
addons = selected_addons
except ImportError:
console.print("[red]questionary not available[/red]")
raise typer.Exit(1) from None

if pin and "://" in template and "ref=" not in template:
sep = "&" if "?" in template else "?"
template = f"{template}{sep}ref={pin}"
Expand Down
95 changes: 95 additions & 0 deletions packages/create-awesome-python-app/tests/test_catalog_resolve.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,7 +6,9 @@
from create_awesome_python_app.catalog import (
CUSTOM_TEMPLATE_SENTINEL,
CatalogResolutionError,
build_extension_choices,
build_template_choices,
group_extension_choices,
is_url_like,
resolve_catalog_spec,
resolve_catalog_specs,
Expand DownExpand Up@@ -101,3 +103,96 @@ def test_build_template_choices_are_searchable() -> None:
assert "backend" in first.search
assert "uv" in first.search
assert choices[-1].value == CUSTOM_TEMPLATE_SENTINEL


def test_build_extension_choices_filters_by_template_type() -> None:
catalog = {
"categories": [
{"slug": "ci", "name": "CI"},
{"slug": "data", "name": "Data"},
],
"templates": [
{
"slug": "fastapi-starter",
"name": "FastAPI Starter",
"url": "file:///templates/fastapi",
"type": "fastapi-backend",
"category": "backend-applications",
}
],
"extensions": [
{
"slug": "github-setup",
"name": "GitHub Setup",
"description": "Actions and Dependabot",
"url": "file:///extensions/github",
"type": ["fastapi-backend"],
"category": "ci",
"labels": ["GitHub", "CI"],
},
{
"slug": "all-projects",
"name": "All Projects",
"url": "file:///extensions/all",
"type": ["all"],
"category": "data",
},
{
"slug": "django-only",
"name": "Django Only",
"url": "file:///extensions/django",
"type": ["django"],
"category": "ci",
},
],
}

choices = build_extension_choices(catalog, "file:///templates/fastapi")

assert [choice.value for choice in choices] == [
"file:///extensions/github",
"file:///extensions/all",
]
assert "dependabot" in choices[0].search
assert "github" in choices[0].title.lower()


def test_group_extension_choices_preserves_category_order() -> None:
catalog = {
"categories": [
{"slug": "ci", "name": "CI"},
{"slug": "data", "name": "Data"},
],
"templates": [
{
"slug": "fastapi-starter",
"name": "FastAPI Starter",
"url": "file:///templates/fastapi",
"type": "fastapi-backend",
"category": "backend-applications",
}
],
"extensions": [
{
"slug": "postgres",
"name": "Postgres",
"url": "file:///extensions/postgres",
"type": ["fastapi-backend"],
"category": "data",
},
{
"slug": "github",
"name": "GitHub",
"url": "file:///extensions/github",
"type": ["fastapi-backend"],
"category": "ci",
},
],
}

grouped = group_extension_choices(
build_extension_choices(catalog, "file:///templates/fastapi")
)

assert list(grouped) == ["ci", "data"]
assert grouped["ci"][0].value == "file:///extensions/github"
85 changes: 84 additions & 1 deletion packages/create-awesome-python-app/tests/test_interactive.py
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
import json
import os
from pathlib import Path

from create_awesome_python_app.cli import _in_ci
from create_awesome_python_app.cli import _in_ci, app
from typer.testing import CliRunner

runner = CliRunner()


def test_in_ci_env(monkeypatch) -> None:
Expand All@@ -9,3 +14,81 @@ def test_in_ci_env(monkeypatch) -> None:
monkeypatch.delenv("CI", raising=False)
# may still be true in this environment; function checks CI only
os.environ.pop("CI", None)


def test_interactive_extension_selection_passes_addon_urls(
tmp_path: Path, monkeypatch
) -> None:
catalog = {
"categories": [{"slug": "tooling", "name": "Tooling"}],
"templates": [
{
"slug": "fastapi-starter",
"name": "FastAPI Starter",
"url": "file:///templates/fastapi",
"type": "fastapi-backend",
"category": "backend-applications",
}
],
"extensions": [
{
"slug": "github-setup",
"name": "GitHub Setup",
"url": "file:///extensions/github",
"type": ["fastapi-backend"],
"category": "tooling",
}
],
}
catalog_file = tmp_path / "templates.json"
catalog_file.write_text(json.dumps(catalog), encoding="utf-8")
monkeypatch.setenv("CPA_CATALOG_URL", f"file://{catalog_file}")
monkeypatch.setenv("CPA_NO_CATALOG_CACHE", "1")

answers = [["tooling"], ["file:///extensions/github"]]

class FakePrompt:
def __init__(self, answer):
self.answer = answer

def ask(self):
return self.answer

def fake_checkbox(*_args, **_kwargs):
return FakePrompt(answers.pop(0))

captured: dict[str, object] = {}

async def fake_create_python_app(project_directory, options, *_args, **_kwargs):
captured["project_directory"] = project_directory
captured["options"] = options

async def fake_check_for_latest_version(_package_name):
return None

monkeypatch.setattr("questionary.checkbox", fake_checkbox)
monkeypatch.setattr(
"create_awesome_python_app.cli.create_python_app",
fake_create_python_app,
)
monkeypatch.setattr(
"create_awesome_python_app.cli.check_for_latest_version",
fake_check_for_latest_version,
)

result = runner.invoke(
app,
[
"--template",
"fastapi-starter",
"--interactive",
"--no-install",
"api",
],
)

assert result.exit_code == 0, result.stdout + result.stderr
assert captured["project_directory"] == "api"
options = captured["options"]
assert isinstance(options, dict)
assert options["addons"] == ["file:///extensions/github"]
Loading