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 19a5947..279c70c 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 @@ -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.""" @@ -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) @@ -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 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 54b4bc0..7cc03c0 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 @@ -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 @@ -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 } @@ -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}" 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 96cf17a..6e0dd54 100644 --- a/packages/create-awesome-python-app/tests/test_catalog_resolve.py +++ b/packages/create-awesome-python-app/tests/test_catalog_resolve.py @@ -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, @@ -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" diff --git a/packages/create-awesome-python-app/tests/test_interactive.py b/packages/create-awesome-python-app/tests/test_interactive.py index 141ad86..77af7d1 100644 --- a/packages/create-awesome-python-app/tests/test_interactive.py +++ b/packages/create-awesome-python-app/tests/test_interactive.py @@ -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: @@ -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"]