Skip to content

Commit 42c7f7a

Browse files
ulises-jeremiasulises-jeremias
andauthored
feat(cli): wire catalog URL to cpa-templates (#142)
Fetch templates.json from cpa-templates with CPA_CATALOG_URL override, disk cache, and fixture fallback. Closes #138 Co-authored-by: ulises-jeremias <ulises.jeremias@users.noreply.github.com>
1 parent ea76a17 commit 42c7f7a

3 files changed

Lines changed: 292 additions & 18 deletions

File tree

fixtures/catalog/templates.json

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,59 @@
11
{
2+
"$schema": "https://raw.githubusercontent.com/Create-Python-App/cpa-templates/main/templates.schema.json",
3+
"categories": [
4+
{
5+
"slug": "backend-applications",
6+
"name": "Backend Applications",
7+
"description": "API and service starters for FastAPI and similar Python backends.",
8+
"details": "Use when the deliverable is an HTTP API or background worker.",
9+
"labels": ["Backend", "API", "Python", "FastAPI"]
10+
},
11+
{
12+
"slug": "tooling",
13+
"name": "Tooling",
14+
"description": "Extensions that add CI, containers, databases, and developer ergonomics.",
15+
"details": "Layer these on top of a compatible template.",
16+
"labels": ["DevOps", "CI", "Docker", "Tooling"]
17+
}
18+
],
219
"templates": [
3-
{"slug": "example-cli", "category": "cli", "url": "file://."}
20+
{
21+
"name": "FastAPI Starter",
22+
"slug": "fastapi-starter",
23+
"description": "Production-ready FastAPI API with uv, Ruff, pytest, and pydantic-settings",
24+
"url": "https://github.com/Create-Python-App/cpa-templates?subdir=templates/fastapi-starter",
25+
"type": "fastapi-backend",
26+
"category": "backend-applications",
27+
"labels": ["FastAPI", "API", "Python", "uv", "Backend"]
28+
},
29+
{
30+
"name": "Example CLI",
31+
"slug": "example-cli",
32+
"description": "Minimal local fixture template for tests",
33+
"url": "file://.",
34+
"type": "cli",
35+
"category": "tooling",
36+
"labels": ["Example"]
37+
}
438
],
5-
"addons": [
6-
{"slug": "ruff-setup", "category": "tooling"}
39+
"extensions": [
40+
{
41+
"name": "Ruff Setup",
42+
"slug": "ruff-setup",
43+
"description": "Local fixture extension for tests",
44+
"url": "file://.",
45+
"type": ["cli", "fastapi-backend"],
46+
"category": "tooling",
47+
"labels": ["Ruff"]
48+
},
49+
{
50+
"name": "GitHub Setup",
51+
"slug": "github-setup",
52+
"description": "GitHub Actions CI, Dependabot, issue templates",
53+
"url": "https://github.com/Create-Python-App/cpa-templates?subdir=extensions/github-setup",
54+
"type": ["fastapi-backend"],
55+
"category": "tooling",
56+
"labels": ["GitHub", "CI"]
57+
}
758
]
859
}
Lines changed: 152 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,182 @@
1-
"""Template catalog listing (stub URL / fixtures)."""
1+
"""Template catalog fetch and listing."""
22

33
from __future__ import annotations
44

55
import json
6+
import os
7+
import time
8+
import urllib.error
9+
import urllib.request
610
from pathlib import Path
11+
from typing import Any
712

13+
from create_python_app_core.paths import default_cache_dir, resolve_source
814
from rich.console import Console
915
from rich.table import Table
1016

11-
console = Console()
17+
from create_awesome_python_app import __version__
18+
19+
console = Console(stderr=True)
20+
21+
DEFAULT_CATALOG_URL = (
22+
"https://raw.githubusercontent.com/Create-Python-App/cpa-templates/main/templates.json"
23+
)
24+
CACHE_TTL_SECONDS = 3600
25+
FETCH_TIMEOUT_SECONDS = 10
26+
USER_AGENT = f"create-awesome-python-app/{__version__} (https://github.com/Create-Python-App/create-python-app)"
1227

1328
_FIXTURE = (
1429
Path(__file__).resolve().parents[4] / "fixtures" / "catalog" / "templates.json"
1530
)
1631

32+
_memory_cache: dict[str, Any] | None = None
33+
_memory_ts: float = 0.0
34+
35+
36+
def catalog_url() -> str:
37+
return os.environ.get("CPA_CATALOG_URL", DEFAULT_CATALOG_URL)
38+
39+
40+
def catalog_cache_path() -> Path:
41+
return default_cache_dir() / "catalog" / "templates.json"
1742

18-
def _load() -> dict:
43+
44+
def _read_json_file(path: Path) -> dict[str, Any]:
45+
return json.loads(path.read_text(encoding="utf-8"))
46+
47+
48+
def _read_fixture() -> dict[str, Any]:
1949
if _FIXTURE.is_file():
20-
return json.loads(_FIXTURE.read_text(encoding="utf-8"))
21-
return {
22-
"templates": [{"slug": "example-cli", "category": "cli", "url": "file://."}],
23-
"addons": [{"slug": "ruff-setup", "category": "tooling"}],
24-
}
50+
return _read_json_file(_FIXTURE)
51+
return {"templates": [], "extensions": [], "categories": []}
52+
53+
54+
def _read_disk_cache() -> dict[str, Any] | None:
55+
path = catalog_cache_path()
56+
if not path.is_file():
57+
return None
58+
try:
59+
return _read_json_file(path)
60+
except json.JSONDecodeError:
61+
return None
62+
63+
64+
def _write_disk_cache(data: dict[str, Any]) -> None:
65+
path = catalog_cache_path()
66+
path.parent.mkdir(parents=True, exist_ok=True)
67+
path.write_text(json.dumps(data), encoding="utf-8")
68+
69+
70+
def _fetch_file_url(url: str) -> dict[str, Any]:
71+
source = resolve_source(url)
72+
if source.local_path is None:
73+
raise OSError(f"Invalid file catalog URL: {url}")
74+
base = source.local_path
75+
if source.subdir:
76+
base = base / source.subdir
77+
catalog_file = base / "templates.json"
78+
if not catalog_file.is_file():
79+
raise FileNotFoundError(f"Catalog not found: {catalog_file}")
80+
return _read_json_file(catalog_file)
81+
82+
83+
def _fetch_remote(url: str) -> dict[str, Any]:
84+
if url.startswith("file://"):
85+
return _fetch_file_url(url)
86+
req = urllib.request.Request(
87+
url,
88+
headers={"Accept": "application/json", "User-Agent": USER_AGENT},
89+
)
90+
with urllib.request.urlopen(req, timeout=FETCH_TIMEOUT_SECONDS) as resp:
91+
payload = resp.read().decode("utf-8")
92+
return json.loads(payload)
93+
94+
95+
def get_catalog_data(*, force_refresh: bool = False) -> dict[str, Any]:
96+
"""Load templates.json from remote URL, disk cache, or local fixture."""
97+
global _memory_cache, _memory_ts
98+
99+
if (
100+
not force_refresh
101+
and _memory_cache is not None
102+
and os.environ.get("CPA_NO_CATALOG_CACHE") != "1"
103+
and time.time() - _memory_ts <= CACHE_TTL_SECONDS
104+
):
105+
return _memory_cache
106+
107+
if os.environ.get("CPA_CATALOG_FIXTURE") == "1":
108+
data = _read_fixture()
109+
else:
110+
url = catalog_url()
111+
try:
112+
data = _fetch_remote(url)
113+
_write_disk_cache(data)
114+
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as err:
115+
disk = _read_disk_cache()
116+
if disk is not None:
117+
console.print(
118+
f"[yellow][cpa] Could not refresh catalog ({err}); using disk cache.[/yellow]"
119+
)
120+
data = disk
121+
else:
122+
fixture = _read_fixture()
123+
if fixture.get("templates"):
124+
console.print(
125+
f"[yellow][cpa] Could not refresh catalog ({err}); using fixture.[/yellow]"
126+
)
127+
data = fixture
128+
else:
129+
raise RuntimeError(f"Failed to load template catalog: {err}") from err
130+
131+
_memory_cache = data
132+
_memory_ts = time.time()
133+
return data
134+
135+
136+
def reset_catalog_cache_for_tests() -> None:
137+
global _memory_cache, _memory_ts
138+
_memory_cache = None
139+
_memory_ts = 0.0
25140

26141

27142
def list_templates() -> None:
28-
data = _load()
143+
data = get_catalog_data()
29144
table = Table(title="Templates")
30145
table.add_column("slug")
31146
table.add_column("category")
147+
table.add_column("type")
32148
for t in data.get("templates", []):
33-
table.add_row(t.get("slug", ""), t.get("category", ""))
149+
table.add_row(
150+
str(t.get("slug", "")),
151+
str(t.get("category", "")),
152+
str(t.get("type", "")),
153+
)
34154
console.print(table)
35155

36156

37157
def list_addons(template_slug: str | None = None) -> None:
38-
_ = template_slug
39-
data = _load()
40-
table = Table(title="Addons")
158+
data = get_catalog_data()
159+
template_type: str | None = None
160+
if template_slug:
161+
for t in data.get("templates", []):
162+
if t.get("slug") == template_slug:
163+
template_type = str(t.get("type", ""))
164+
break
165+
166+
table = Table(title="Extensions")
41167
table.add_column("slug")
42168
table.add_column("category")
43-
for t in data.get("addons", []):
44-
table.add_row(t.get("slug", ""), t.get("category", ""))
169+
table.add_column("type")
170+
for ext in data.get("extensions", data.get("addons", [])):
171+
ext_types = ext.get("type", [])
172+
if isinstance(ext_types, str):
173+
ext_types = [ext_types]
174+
if template_type and template_type not in ext_types:
175+
continue
176+
type_label = ", ".join(ext_types) if isinstance(ext_types, list) else str(ext_types)
177+
table.add_row(
178+
str(ext.get("slug", "")),
179+
str(ext.get("category", "")),
180+
type_label,
181+
)
45182
console.print(table)
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""Catalog fetch tests."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
from pathlib import Path
7+
from unittest.mock import patch
8+
9+
import pytest
10+
11+
from create_awesome_python_app.catalog import (
12+
DEFAULT_CATALOG_URL,
13+
catalog_cache_path,
14+
catalog_url,
15+
get_catalog_data,
16+
reset_catalog_cache_for_tests,
17+
)
18+
19+
FIXTURE_PATH = (
20+
Path(__file__).resolve().parents[3] / "fixtures" / "catalog" / "templates.json"
21+
)
22+
23+
24+
@pytest.fixture(autouse=True)
25+
def _reset_cache(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
26+
reset_catalog_cache_for_tests()
27+
monkeypatch.setenv("CPA_CACHE_DIR", str(tmp_path / "cache"))
28+
29+
30+
def test_default_catalog_url(monkeypatch: pytest.MonkeyPatch) -> None:
31+
monkeypatch.delenv("CPA_CATALOG_URL", raising=False)
32+
assert "Create-Python-App/cpa-templates" in catalog_url()
33+
monkeypatch.setenv("CPA_CATALOG_URL", "https://example.com/templates.json")
34+
assert catalog_url() == "https://example.com/templates.json"
35+
36+
37+
def test_get_catalog_data_fetches_and_caches(tmp_path: Path) -> None:
38+
payload = json.loads(FIXTURE_PATH.read_text(encoding="utf-8"))
39+
40+
class FakeResponse:
41+
def read(self) -> bytes:
42+
return json.dumps(payload).encode("utf-8")
43+
44+
def __enter__(self) -> "FakeResponse":
45+
return self
46+
47+
def __exit__(self, *args: object) -> None:
48+
return None
49+
50+
with patch(
51+
"create_awesome_python_app.catalog.urllib.request.urlopen",
52+
return_value=FakeResponse(),
53+
):
54+
data = get_catalog_data(force_refresh=True)
55+
56+
assert any(t["slug"] == "fastapi-starter" for t in data["templates"])
57+
assert catalog_cache_path().is_file()
58+
59+
60+
def test_get_catalog_data_fixture_fallback(
61+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
62+
) -> None:
63+
monkeypatch.setenv("CPA_CATALOG_FIXTURE", "1")
64+
data = get_catalog_data(force_refresh=True)
65+
assert data["templates"]
66+
67+
68+
def test_get_catalog_data_disk_fallback_on_network_error(
69+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
70+
) -> None:
71+
payload = {"templates": [{"slug": "cached"}], "extensions": [], "categories": []}
72+
cache_file = catalog_cache_path()
73+
cache_file.parent.mkdir(parents=True, exist_ok=True)
74+
cache_file.write_text(json.dumps(payload), encoding="utf-8")
75+
76+
with patch(
77+
"create_awesome_python_app.catalog._fetch_remote",
78+
side_effect=OSError("network down"),
79+
):
80+
data = get_catalog_data(force_refresh=True)
81+
82+
assert data["templates"][0]["slug"] == "cached"
83+
84+
85+
def test_default_url_points_to_cpa_templates() -> None:
86+
assert DEFAULT_CATALOG_URL.endswith("/cpa-templates/main/templates.json")

0 commit comments

Comments
 (0)