Skip to content

Commit ff48640

Browse files
fix(cli): resolve catalog slugs to template/extension URLs before scaffold
Closes #160 and #161. Mirrors CNA options.ts slug lookup so --template fastapi-starter and --addons github-setup work end-to-end. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 43df114 commit ff48640

4 files changed

Lines changed: 227 additions & 0 deletions

File tree

packages/create-awesome-python-app/src/create_awesome_python_app/catalog.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,43 @@
1818

1919
console = Console(stderr=True)
2020

21+
22+
class CatalogResolutionError(ValueError):
23+
"""Raised when a template or extension slug is not in the catalog."""
24+
25+
def __init__(self, spec: str) -> None:
26+
self.spec = spec
27+
super().__init__(
28+
f"Invalid catalog slug: '{spec}'. "
29+
"Run --list-templates / --list-addons or pass a full URL."
30+
)
31+
32+
33+
def is_url_like(spec: str) -> bool:
34+
"""Return True when *spec* is already a URL or git SSH target."""
35+
return "://" in spec or spec.startswith("git@")
36+
37+
38+
def resolve_catalog_spec(spec: str, *, catalog: dict[str, Any] | None = None) -> str:
39+
"""Resolve a catalog slug to its registry URL, or return URL-like specs unchanged."""
40+
if is_url_like(spec):
41+
return spec
42+
data = catalog if catalog is not None else get_catalog_data()
43+
for entry in data.get("templates", []):
44+
if entry.get("slug") == spec:
45+
return str(entry["url"])
46+
for entry in data.get("extensions", data.get("addons", [])):
47+
if entry.get("slug") == spec:
48+
return str(entry["url"])
49+
raise CatalogResolutionError(spec)
50+
51+
52+
def resolve_catalog_specs(
53+
specs: list[str], *, catalog: dict[str, Any] | None = None
54+
) -> list[str]:
55+
return [resolve_catalog_spec(spec, catalog=catalog) for spec in specs]
56+
57+
2158
DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/Create-Python-App/cpa-templates/main/templates.json"
2259
CACHE_TTL_SECONDS = 3600
2360
FETCH_TIMEOUT_SECONDS = 10
@@ -72,6 +109,8 @@ def _fetch_file_url(url: str) -> dict[str, Any]:
72109
base = source.local_path
73110
if source.subdir:
74111
base = base / source.subdir
112+
if base.is_file():
113+
return _read_json_file(base)
75114
catalog_file = base / "templates.json"
76115
if not catalog_file.is_file():
77116
raise FileNotFoundError(f"Catalog not found: {catalog_file}")

packages/create-awesome-python-app/src/create_awesome_python_app/cli.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,20 @@ def scaffold(
121121
console.print("[red]--template is required in non-interactive mode[/red]")
122122
raise typer.Exit(2)
123123

124+
from create_awesome_python_app.catalog import (
125+
CatalogResolutionError,
126+
resolve_catalog_spec,
127+
resolve_catalog_specs,
128+
)
129+
130+
try:
131+
template = resolve_catalog_spec(template)
132+
addons = resolve_catalog_specs(addons or [])
133+
extend = resolve_catalog_specs(extend or [])
134+
except CatalogResolutionError as err:
135+
console.print(f"[red]{err}[/red]")
136+
raise typer.Exit(2) from err
137+
124138
if pin and "://" in template and "ref=" not in template:
125139
sep = "&" if "?" in template else "?"
126140
template = f"{template}{sep}ref={pin}"
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Catalog slug resolution tests."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
from create_awesome_python_app.catalog import (
7+
CatalogResolutionError,
8+
is_url_like,
9+
resolve_catalog_spec,
10+
resolve_catalog_specs,
11+
)
12+
13+
SAMPLE_CATALOG = {
14+
"templates": [
15+
{
16+
"slug": "fastapi-starter",
17+
"url": "https://github.com/Create-Python-App/cpa-templates?subdir=templates/fastapi-starter",
18+
}
19+
],
20+
"extensions": [
21+
{
22+
"slug": "github-setup",
23+
"url": "https://github.com/Create-Python-App/cpa-templates?subdir=extensions/github-setup",
24+
}
25+
],
26+
}
27+
28+
29+
def test_is_url_like() -> None:
30+
assert is_url_like("https://github.com/org/repo")
31+
assert is_url_like("file:///tmp/foo")
32+
assert is_url_like("git@github.com:org/repo.git")
33+
assert not is_url_like("fastapi-starter")
34+
35+
36+
def test_resolve_template_slug() -> None:
37+
url = resolve_catalog_spec("fastapi-starter", catalog=SAMPLE_CATALOG)
38+
assert "cpa-templates" in url
39+
assert "fastapi-starter" in url
40+
41+
42+
def test_resolve_extension_slug() -> None:
43+
url = resolve_catalog_spec("github-setup", catalog=SAMPLE_CATALOG)
44+
assert "github-setup" in url
45+
46+
47+
def test_resolve_url_unchanged() -> None:
48+
spec = "file:///tmp/template?subdir=foo"
49+
assert resolve_catalog_spec(spec, catalog=SAMPLE_CATALOG) == spec
50+
51+
52+
def test_unknown_slug_raises() -> None:
53+
with pytest.raises(CatalogResolutionError, match="unknown-slug"):
54+
resolve_catalog_spec("unknown-slug", catalog=SAMPLE_CATALOG)
55+
56+
57+
def test_resolve_catalog_specs_batch() -> None:
58+
resolved = resolve_catalog_specs(
59+
["github-setup", "file:///ext"],
60+
catalog=SAMPLE_CATALOG,
61+
)
62+
assert len(resolved) == 2
63+
assert resolved[1] == "file:///ext"

packages/create-awesome-python-app/tests/test_cpa_templates_integration.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ def test_scaffold_fastapi_starter_from_cpa_templates(
3030
) -> None:
3131
monkeypatch.setenv("CI", "1")
3232
monkeypatch.setenv("CPA_SKIP_GIT", "1")
33+
monkeypatch.setenv("CPA_CACHE_DIR", str(tmp_path / "cpa-cache"))
3334
dest = tmp_path / "api"
3435
template_url = f"file://{CPA_TEMPLATES_ROOT}?subdir=templates/fastapi-starter"
3536

@@ -67,6 +68,115 @@ def test_scaffold_fastapi_starter_from_cpa_templates(
6768
assert tests.returncode == 0, tests.stdout + tests.stderr
6869

6970

71+
@pytest.mark.skipif(
72+
not _cpa_templates_available(),
73+
reason="cpa-templates checkout not available (set CPA_TEMPLATES_ROOT)",
74+
)
75+
def test_scaffold_fastapi_starter_via_catalog_slug(
76+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
77+
) -> None:
78+
"""Scaffold using --template fastapi-starter slug (issue #160 / #161)."""
79+
import json
80+
81+
monkeypatch.setenv("CI", "1")
82+
monkeypatch.setenv("CPA_SKIP_GIT", "1")
83+
monkeypatch.setenv("CPA_CACHE_DIR", str(tmp_path / "cpa-cache"))
84+
monkeypatch.setenv("CPA_CACHE_DIR", str(tmp_path / "cpa-cache"))
85+
catalog = {
86+
"templates": [
87+
{
88+
"slug": "fastapi-starter",
89+
"url": (
90+
f"file://{CPA_TEMPLATES_ROOT}"
91+
"?subdir=templates/fastapi-starter"
92+
),
93+
}
94+
],
95+
"extensions": [],
96+
"categories": [],
97+
}
98+
catalog_file = tmp_path / "templates.json"
99+
catalog_file.write_text(json.dumps(catalog), encoding="utf-8")
100+
monkeypatch.setenv("CPA_CATALOG_URL", f"file://{catalog_file}")
101+
monkeypatch.setenv("CPA_NO_CATALOG_CACHE", "1")
102+
103+
dest = tmp_path / "api-slug"
104+
result = subprocess.run(
105+
[
106+
"uv",
107+
"run",
108+
"create-awesome-python-app",
109+
"--template",
110+
"fastapi-starter",
111+
"--no-interactive",
112+
"--no-install",
113+
str(dest),
114+
],
115+
cwd=_REPO_ROOT,
116+
capture_output=True,
117+
text=True,
118+
check=False,
119+
)
120+
assert result.returncode == 0, result.stdout + result.stderr
121+
assert (dest / "app" / "main.py").is_file()
122+
123+
124+
@pytest.mark.skipif(
125+
not (_cpa_templates_available() and GITHUB_SETUP.is_dir()),
126+
reason="cpa-templates extensions not available",
127+
)
128+
def test_scaffold_via_catalog_addon_slug(
129+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
130+
) -> None:
131+
import json
132+
133+
monkeypatch.setenv("CI", "1")
134+
monkeypatch.setenv("CPA_SKIP_GIT", "1")
135+
monkeypatch.setenv("CPA_CACHE_DIR", str(tmp_path / "cpa-cache"))
136+
repo = CPA_TEMPLATES_ROOT
137+
catalog = {
138+
"templates": [
139+
{
140+
"slug": "fastapi-starter",
141+
"url": f"file://{repo}?subdir=templates/fastapi-starter",
142+
}
143+
],
144+
"extensions": [
145+
{
146+
"slug": "github-setup",
147+
"url": f"file://{repo}?subdir=extensions/github-setup",
148+
}
149+
],
150+
"categories": [],
151+
}
152+
catalog_file = tmp_path / "templates.json"
153+
catalog_file.write_text(json.dumps(catalog), encoding="utf-8")
154+
monkeypatch.setenv("CPA_CATALOG_URL", f"file://{catalog_file}")
155+
monkeypatch.setenv("CPA_NO_CATALOG_CACHE", "1")
156+
157+
dest = tmp_path / "api-addon-slug"
158+
result = subprocess.run(
159+
[
160+
"uv",
161+
"run",
162+
"create-awesome-python-app",
163+
"--template",
164+
"fastapi-starter",
165+
"--addons",
166+
"github-setup",
167+
"--no-interactive",
168+
"--no-install",
169+
str(dest),
170+
],
171+
cwd=_REPO_ROOT,
172+
capture_output=True,
173+
text=True,
174+
check=False,
175+
)
176+
assert result.returncode == 0, result.stdout + result.stderr
177+
assert (dest / ".github" / "workflows" / "ci.yml").is_file()
178+
179+
70180
@pytest.mark.skipif(
71181
not (_cpa_templates_available() and GITHUB_SETUP.is_dir()),
72182
reason="cpa-templates extensions not available",
@@ -76,6 +186,7 @@ def test_scaffold_fastapi_with_github_setup_extension(
76186
) -> None:
77187
monkeypatch.setenv("CI", "1")
78188
monkeypatch.setenv("CPA_SKIP_GIT", "1")
189+
monkeypatch.setenv("CPA_CACHE_DIR", str(tmp_path / "cpa-cache"))
79190
dest = tmp_path / "api-ext"
80191
repo = CPA_TEMPLATES_ROOT
81192
result = subprocess.run(

0 commit comments

Comments
 (0)