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 dc0741f..d2134b1 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 @@ -18,6 +18,43 @@ console = Console(stderr=True) + +class CatalogResolutionError(ValueError): + """Raised when a template or extension slug is not in the catalog.""" + + def __init__(self, spec: str) -> None: + self.spec = spec + super().__init__( + f"Invalid catalog slug: '{spec}'. " + "Run --list-templates / --list-addons or pass a full URL." + ) + + +def is_url_like(spec: str) -> bool: + """Return True when *spec* is already a URL or git SSH target.""" + return "://" in spec or spec.startswith("git@") + + +def resolve_catalog_spec(spec: str, *, catalog: dict[str, Any] | None = None) -> str: + """Resolve a catalog slug to its registry URL.""" + if is_url_like(spec): + return spec + data = catalog if catalog is not None else get_catalog_data() + for entry in data.get("templates", []): + if entry.get("slug") == spec: + return str(entry["url"]) + for entry in data.get("extensions", data.get("addons", [])): + if entry.get("slug") == spec: + return str(entry["url"]) + raise CatalogResolutionError(spec) + + +def resolve_catalog_specs( + specs: list[str], *, catalog: dict[str, Any] | None = None +) -> list[str]: + return [resolve_catalog_spec(spec, catalog=catalog) for spec in specs] + + DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/Create-Python-App/cpa-templates/main/templates.json" CACHE_TTL_SECONDS = 3600 FETCH_TIMEOUT_SECONDS = 10 @@ -72,6 +109,8 @@ def _fetch_file_url(url: str) -> dict[str, Any]: base = source.local_path if source.subdir: base = base / source.subdir + if base.is_file(): + return _read_json_file(base) catalog_file = base / "templates.json" if not catalog_file.is_file(): raise FileNotFoundError(f"Catalog not found: {catalog_file}") 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 b90c6c3..e7ba59a 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 @@ -121,6 +121,20 @@ def scaffold( console.print("[red]--template is required in non-interactive mode[/red]") raise typer.Exit(2) + from create_awesome_python_app.catalog import ( + CatalogResolutionError, + resolve_catalog_spec, + resolve_catalog_specs, + ) + + try: + template = resolve_catalog_spec(template) + addons = resolve_catalog_specs(addons or []) + extend = resolve_catalog_specs(extend or []) + except CatalogResolutionError as err: + console.print(f"[red]{err}[/red]") + raise typer.Exit(2) from err + 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 new file mode 100644 index 0000000..0ae84e4 --- /dev/null +++ b/packages/create-awesome-python-app/tests/test_catalog_resolve.py @@ -0,0 +1,63 @@ +"""Catalog slug resolution tests.""" + +from __future__ import annotations + +import pytest +from create_awesome_python_app.catalog import ( + CatalogResolutionError, + is_url_like, + resolve_catalog_spec, + resolve_catalog_specs, +) + +SAMPLE_CATALOG = { + "templates": [ + { + "slug": "fastapi-starter", + "url": "https://github.com/Create-Python-App/cpa-templates?subdir=templates/fastapi-starter", + } + ], + "extensions": [ + { + "slug": "github-setup", + "url": "https://github.com/Create-Python-App/cpa-templates?subdir=extensions/github-setup", + } + ], +} + + +def test_is_url_like() -> None: + assert is_url_like("https://github.com/org/repo") + assert is_url_like("file:///tmp/foo") + assert is_url_like("git@github.com:org/repo.git") + assert not is_url_like("fastapi-starter") + + +def test_resolve_template_slug() -> None: + url = resolve_catalog_spec("fastapi-starter", catalog=SAMPLE_CATALOG) + assert "cpa-templates" in url + assert "fastapi-starter" in url + + +def test_resolve_extension_slug() -> None: + url = resolve_catalog_spec("github-setup", catalog=SAMPLE_CATALOG) + assert "github-setup" in url + + +def test_resolve_url_unchanged() -> None: + spec = "file:///tmp/template?subdir=foo" + assert resolve_catalog_spec(spec, catalog=SAMPLE_CATALOG) == spec + + +def test_unknown_slug_raises() -> None: + with pytest.raises(CatalogResolutionError, match="unknown-slug"): + resolve_catalog_spec("unknown-slug", catalog=SAMPLE_CATALOG) + + +def test_resolve_catalog_specs_batch() -> None: + resolved = resolve_catalog_specs( + ["github-setup", "file:///ext"], + catalog=SAMPLE_CATALOG, + ) + assert len(resolved) == 2 + assert resolved[1] == "file:///ext" diff --git a/packages/create-awesome-python-app/tests/test_cpa_templates_integration.py b/packages/create-awesome-python-app/tests/test_cpa_templates_integration.py index 494a363..adac7be 100644 --- a/packages/create-awesome-python-app/tests/test_cpa_templates_integration.py +++ b/packages/create-awesome-python-app/tests/test_cpa_templates_integration.py @@ -30,6 +30,7 @@ def test_scaffold_fastapi_starter_from_cpa_templates( ) -> None: monkeypatch.setenv("CI", "1") monkeypatch.setenv("CPA_SKIP_GIT", "1") + monkeypatch.setenv("CPA_CACHE_DIR", str(tmp_path / "cpa-cache")) dest = tmp_path / "api" template_url = f"file://{CPA_TEMPLATES_ROOT}?subdir=templates/fastapi-starter" @@ -67,6 +68,114 @@ def test_scaffold_fastapi_starter_from_cpa_templates( assert tests.returncode == 0, tests.stdout + tests.stderr +@pytest.mark.skipif( + not _cpa_templates_available(), + reason="cpa-templates checkout not available (set CPA_TEMPLATES_ROOT)", +) +def test_scaffold_fastapi_starter_via_catalog_slug( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Scaffold using --template fastapi-starter slug (issue #160 / #161).""" + import json + + monkeypatch.setenv("CI", "1") + monkeypatch.setenv("CPA_SKIP_GIT", "1") + monkeypatch.setenv("CPA_CACHE_DIR", str(tmp_path / "cpa-cache")) + monkeypatch.setenv("CPA_CACHE_DIR", str(tmp_path / "cpa-cache")) + catalog = { + "templates": [ + { + "slug": "fastapi-starter", + "url": ( + f"file://{CPA_TEMPLATES_ROOT}?subdir=templates/fastapi-starter" + ), + } + ], + "extensions": [], + "categories": [], + } + 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") + + dest = tmp_path / "api-slug" + result = subprocess.run( + [ + "uv", + "run", + "create-awesome-python-app", + "--template", + "fastapi-starter", + "--no-interactive", + "--no-install", + str(dest), + ], + cwd=_REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert (dest / "app" / "main.py").is_file() + + +@pytest.mark.skipif( + not (_cpa_templates_available() and GITHUB_SETUP.is_dir()), + reason="cpa-templates extensions not available", +) +def test_scaffold_via_catalog_addon_slug( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import json + + monkeypatch.setenv("CI", "1") + monkeypatch.setenv("CPA_SKIP_GIT", "1") + monkeypatch.setenv("CPA_CACHE_DIR", str(tmp_path / "cpa-cache")) + repo = CPA_TEMPLATES_ROOT + catalog = { + "templates": [ + { + "slug": "fastapi-starter", + "url": f"file://{repo}?subdir=templates/fastapi-starter", + } + ], + "extensions": [ + { + "slug": "github-setup", + "url": f"file://{repo}?subdir=extensions/github-setup", + } + ], + "categories": [], + } + 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") + + dest = tmp_path / "api-addon-slug" + result = subprocess.run( + [ + "uv", + "run", + "create-awesome-python-app", + "--template", + "fastapi-starter", + "--addons", + "github-setup", + "--no-interactive", + "--no-install", + str(dest), + ], + cwd=_REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert (dest / ".github" / "workflows" / "ci.yml").is_file() + + @pytest.mark.skipif( not (_cpa_templates_available() and GITHUB_SETUP.is_dir()), reason="cpa-templates extensions not available", @@ -76,6 +185,7 @@ def test_scaffold_fastapi_with_github_setup_extension( ) -> None: monkeypatch.setenv("CI", "1") monkeypatch.setenv("CPA_SKIP_GIT", "1") + monkeypatch.setenv("CPA_CACHE_DIR", str(tmp_path / "cpa-cache")) dest = tmp_path / "api-ext" repo = CPA_TEMPLATES_ROOT result = subprocess.run(