Skip to content

Commit 5c95043

Browse files
fix(cli): resolve catalog slugs to template/extension URLs
Closes #160. Closes #161.
1 parent 43df114 commit 5c95043

4 files changed

Lines changed: 226 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."""
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: 110 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,114 @@ 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}?subdir=templates/fastapi-starter"
91+
),
92+
}
93+
],
94+
"extensions": [],
95+
"categories": [],
96+
}
97+
catalog_file = tmp_path / "templates.json"
98+
catalog_file.write_text(json.dumps(catalog), encoding="utf-8")
99+
monkeypatch.setenv("CPA_CATALOG_URL", f"file://{catalog_file}")
100+
monkeypatch.setenv("CPA_NO_CATALOG_CACHE", "1")
101+
102+
dest = tmp_path / "api-slug"
103+
result = subprocess.run(
104+
[
105+
"uv",
106+
"run",
107+
"create-awesome-python-app",
108+
"--template",
109+
"fastapi-starter",
110+
"--no-interactive",
111+
"--no-install",
112+
str(dest),
113+
],
114+
cwd=_REPO_ROOT,
115+
capture_output=True,
116+
text=True,
117+
check=False,
118+
)
119+
assert result.returncode == 0, result.stdout + result.stderr
120+
assert (dest / "app" / "main.py").is_file()
121+
122+
123+
@pytest.mark.skipif(
124+
not (_cpa_templates_available() and GITHUB_SETUP.is_dir()),
125+
reason="cpa-templates extensions not available",
126+
)
127+
def test_scaffold_via_catalog_addon_slug(
128+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
129+
) -> None:
130+
import json
131+
132+
monkeypatch.setenv("CI", "1")
133+
monkeypatch.setenv("CPA_SKIP_GIT", "1")
134+
monkeypatch.setenv("CPA_CACHE_DIR", str(tmp_path / "cpa-cache"))
135+
repo = CPA_TEMPLATES_ROOT
136+
catalog = {
137+
"templates": [
138+
{
139+
"slug": "fastapi-starter",
140+
"url": f"file://{repo}?subdir=templates/fastapi-starter",
141+
}
142+
],
143+
"extensions": [
144+
{
145+
"slug": "github-setup",
146+
"url": f"file://{repo}?subdir=extensions/github-setup",
147+
}
148+
],
149+
"categories": [],
150+
}
151+
catalog_file = tmp_path / "templates.json"
152+
catalog_file.write_text(json.dumps(catalog), encoding="utf-8")
153+
monkeypatch.setenv("CPA_CATALOG_URL", f"file://{catalog_file}")
154+
monkeypatch.setenv("CPA_NO_CATALOG_CACHE", "1")
155+
156+
dest = tmp_path / "api-addon-slug"
157+
result = subprocess.run(
158+
[
159+
"uv",
160+
"run",
161+
"create-awesome-python-app",
162+
"--template",
163+
"fastapi-starter",
164+
"--addons",
165+
"github-setup",
166+
"--no-interactive",
167+
"--no-install",
168+
str(dest),
169+
],
170+
cwd=_REPO_ROOT,
171+
capture_output=True,
172+
text=True,
173+
check=False,
174+
)
175+
assert result.returncode == 0, result.stdout + result.stderr
176+
assert (dest / ".github" / "workflows" / "ci.yml").is_file()
177+
178+
70179
@pytest.mark.skipif(
71180
not (_cpa_templates_available() and GITHUB_SETUP.is_dir()),
72181
reason="cpa-templates extensions not available",
@@ -76,6 +185,7 @@ def test_scaffold_fastapi_with_github_setup_extension(
76185
) -> None:
77186
monkeypatch.setenv("CI", "1")
78187
monkeypatch.setenv("CPA_SKIP_GIT", "1")
188+
monkeypatch.setenv("CPA_CACHE_DIR", str(tmp_path / "cpa-cache"))
79189
dest = tmp_path / "api-ext"
80190
repo = CPA_TEMPLATES_ROOT
81191
result = subprocess.run(

0 commit comments

Comments
 (0)