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@@ -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
Expand DownExpand Up@@ -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}")
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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}"
Expand Down
63 changes: 63 additions & 0 deletions packages/create-awesome-python-app/tests/test_catalog_resolve.py
Original file line numberDiff line numberDiff line change
@@ -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"
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"

Expand DownExpand Up@@ -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",
Expand All@@ -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(
Expand Down
Loading