Skip to content

Commit 03f3ac7

Browse files
feat(cli): add --fixture flag for catalog fixture mode
Enable CPA_CATALOG_FIXTURE via --fixture [dir] (CNA parity) and resolve CPA_FIXTURE_DIR before the in-memory catalog cache. Closes #227 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 6012c89 commit 03f3ac7

4 files changed

Lines changed: 264 additions & 36 deletions

File tree

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

Lines changed: 84 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -344,9 +344,9 @@ def group_extension_choices(
344344
FETCH_TIMEOUT_SECONDS = 10
345345
USER_AGENT = f"create-awesome-python-app/{__version__} (https://github.com/Create-Python-App/create-python-app)"
346346

347-
_FIXTURE = (
348-
Path(__file__).resolve().parents[4] / "fixtures" / "catalog" / "templates.json"
349-
)
347+
_AUTO_FIXTURE_DIR = Path(__file__).resolve().parents[4]
348+
_SENTINEL = object()
349+
_fixture_root_override: Path | None | object = _SENTINEL
350350

351351
_memory_cache: dict[str, Any] | None = None
352352
_memory_ts: float = 0.0
@@ -360,13 +360,54 @@ def catalog_cache_path() -> Path:
360360
return default_cache_dir() / "catalog" / "templates.json"
361361

362362

363+
def resolve_fixture_root() -> Path | None:
364+
"""Resolve the repo root that contains ``fixtures/catalog/templates.json``.
365+
366+
Priority: ``CPA_FIXTURE_DIR`` → package-relative monorepo root → ``cwd``.
367+
"""
368+
if _fixture_root_override is not _SENTINEL:
369+
return _fixture_root_override # type: ignore[return-value]
370+
371+
env = os.environ.get("CPA_FIXTURE_DIR", "").strip()
372+
if env:
373+
return Path(env).expanduser().resolve()
374+
375+
auto = _AUTO_FIXTURE_DIR
376+
if (auto / "fixtures" / "catalog" / "templates.json").is_file():
377+
return auto
378+
379+
cwd = Path.cwd()
380+
if (cwd / "fixtures" / "catalog" / "templates.json").is_file():
381+
return cwd
382+
return None
383+
384+
385+
def set_fixture_root_for_tests(root: Path | None) -> None:
386+
"""Override fixture root (test helper)."""
387+
global _fixture_root_override
388+
_fixture_root_override = root
389+
390+
391+
def reset_fixture_root_for_tests() -> None:
392+
global _fixture_root_override
393+
_fixture_root_override = _SENTINEL
394+
395+
396+
def fixture_catalog_path() -> Path | None:
397+
root = resolve_fixture_root()
398+
if root is None:
399+
return None
400+
return root / "fixtures" / "catalog" / "templates.json"
401+
402+
363403
def _read_json_file(path: Path) -> dict[str, Any]:
364404
return json.loads(path.read_text(encoding="utf-8"))
365405

366406

367407
def _read_fixture() -> dict[str, Any]:
368-
if _FIXTURE.is_file():
369-
return _read_json_file(_FIXTURE)
408+
path = fixture_catalog_path()
409+
if path is not None and path.is_file():
410+
return _read_json_file(path)
370411
return {"templates": [], "extensions": [], "categories": []}
371412

372413

@@ -417,6 +458,19 @@ def get_catalog_data(*, force_refresh: bool = False) -> dict[str, Any]:
417458
"""Load templates.json from remote URL, disk cache, or local fixture."""
418459
global _memory_cache, _memory_ts
419460

461+
if os.environ.get("CPA_CATALOG_FIXTURE") == "1":
462+
path = fixture_catalog_path()
463+
if path is None or not path.is_file():
464+
raise RuntimeError(
465+
"Fixture mode is enabled (CPA_CATALOG_FIXTURE=1) but the fixture "
466+
"root could not be resolved. Set CPA_FIXTURE_DIR to the repo root "
467+
"containing fixtures/catalog/templates.json."
468+
)
469+
data = _read_fixture()
470+
_memory_cache = data
471+
_memory_ts = time.time()
472+
return data
473+
420474
if (
421475
not force_refresh
422476
and _memory_cache is not None
@@ -425,38 +479,35 @@ def get_catalog_data(*, force_refresh: bool = False) -> dict[str, Any]:
425479
):
426480
return _memory_cache
427481

428-
if os.environ.get("CPA_CATALOG_FIXTURE") == "1":
429-
data = _read_fixture()
430-
else:
431-
url = catalog_url()
432-
try:
433-
data = _fetch_remote(url)
434-
_write_disk_cache(data)
435-
except (
436-
urllib.error.URLError,
437-
TimeoutError,
438-
OSError,
439-
json.JSONDecodeError,
440-
) as err:
441-
disk = _read_disk_cache()
442-
if disk is not None:
482+
url = catalog_url()
483+
try:
484+
data = _fetch_remote(url)
485+
_write_disk_cache(data)
486+
except (
487+
urllib.error.URLError,
488+
TimeoutError,
489+
OSError,
490+
json.JSONDecodeError,
491+
) as err:
492+
disk = _read_disk_cache()
493+
if disk is not None:
494+
console.print(
495+
"[yellow][cpa] Could not refresh catalog "
496+
f"({err}); using disk cache.[/yellow]"
497+
)
498+
data = disk
499+
else:
500+
fixture = _read_fixture()
501+
if fixture.get("templates"):
443502
console.print(
444503
"[yellow][cpa] Could not refresh catalog "
445-
f"({err}); using disk cache.[/yellow]"
504+
f"({err}); using fixture.[/yellow]"
446505
)
447-
data = disk
506+
data = fixture
448507
else:
449-
fixture = _read_fixture()
450-
if fixture.get("templates"):
451-
console.print(
452-
"[yellow][cpa] Could not refresh catalog "
453-
f"({err}); using fixture.[/yellow]"
454-
)
455-
data = fixture
456-
else:
457-
raise RuntimeError(
458-
f"Failed to load template catalog: {err}"
459-
) from err
508+
raise RuntimeError(
509+
f"Failed to load template catalog: {err}"
510+
) from err
460511

461512
_memory_cache = data
462513
_memory_ts = time.time()

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

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import asyncio
66
import os
7+
import sys
78
from pathlib import Path
89
from typing import Any
910

@@ -25,6 +26,9 @@
2526

2627
from create_awesome_python_app import __version__
2728

29+
# Sentinel for bare ``--fixture`` (optional DIR rewritten in argv preprocess).
30+
_FIXTURE_AUTO = "__CPA_FIXTURE_AUTO__"
31+
2832
app = typer.Typer(
2933
name="create-awesome-python-app",
3034
help="Composable scaffolding CLI for production-ready Python apps.",
@@ -36,6 +40,43 @@
3640
console = Console(stderr=True)
3741

3842

43+
def _preprocess_fixture_argv(argv: list[str] | None = None) -> list[str]:
44+
"""Rewrite bare ``--fixture`` to ``--fixture=__CPA_FIXTURE_AUTO__``.
45+
46+
Typer/Click requires an option argument; Commander allows ``--fixture [dir]``.
47+
This keeps CNA-compatible UX: ``--fixture`` alone enables auto-detect mode.
48+
"""
49+
raw = list(sys.argv if argv is None else argv)
50+
if not raw:
51+
return raw
52+
out = [raw[0]]
53+
i = 1
54+
while i < len(raw):
55+
arg = raw[i]
56+
if arg == "--fixture":
57+
if i + 1 < len(raw) and not raw[i + 1].startswith("-"):
58+
out.extend(["--fixture", raw[i + 1]])
59+
i += 2
60+
else:
61+
out.append(f"--fixture={_FIXTURE_AUTO}")
62+
i += 1
63+
continue
64+
out.append(arg)
65+
i += 1
66+
if argv is None:
67+
sys.argv = out
68+
return out
69+
70+
71+
def apply_fixture_mode(fixture: str | None) -> None:
72+
"""Translate ``--fixture`` into ``CPA_CATALOG_FIXTURE`` / ``CPA_FIXTURE_DIR``."""
73+
if fixture is None and os.environ.get("CPA_CATALOG_FIXTURE") != "1":
74+
return
75+
os.environ["CPA_CATALOG_FIXTURE"] = "1"
76+
if fixture is not None and fixture != _FIXTURE_AUTO and fixture != "":
77+
os.environ["CPA_FIXTURE_DIR"] = fixture
78+
79+
3980
def _in_ci() -> bool:
4081
return os.environ.get("CI", "").lower() in {"1", "true", "yes"}
4182

@@ -159,9 +200,8 @@ def main() -> None:
159200
`create-awesome-python-app cache dir` works (Typer would otherwise
160201
treat `cache` as project_directory).
161202
"""
162-
import sys
163-
164203
check_python_version(">=3.12", "create-awesome-python-app")
204+
_preprocess_fixture_argv()
165205
if len(sys.argv) > 1 and sys.argv[1] == "cache":
166206
sys.argv = [sys.argv[0], *sys.argv[2:]]
167207
cache_app(prog_name="create-awesome-python-app cache")
@@ -192,6 +232,15 @@ def scaffold(
192232
refresh: str | None = typer.Option(None, "--refresh"),
193233
strict_version: bool = typer.Option(False, "--strict-version"),
194234
keep_on_failure: bool = typer.Option(False, "--keep-on-failure"),
235+
fixture: str | None = typer.Option(
236+
None,
237+
"--fixture",
238+
help=(
239+
"Load the template catalog from the local fixtures/ directory "
240+
"instead of the network (optional DIR = repo root; also "
241+
"CPA_FIXTURE_DIR / CPA_CATALOG_FIXTURE)"
242+
),
243+
),
195244
) -> None:
196245
if version:
197246
console.print(__version__)
@@ -201,6 +250,10 @@ def scaffold(
201250
if ctx.invoked_subcommand is not None:
202251
return
203252

253+
# Translate --fixture into env vars before catalog loads
254+
# (--list-templates / interactive / scaffold).
255+
apply_fixture_mode(fixture)
256+
204257
if list_templates or list_addons:
205258
from create_awesome_python_app.catalog import list_addons as la
206259
from create_awesome_python_app.catalog import list_templates as lt

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
catalog_url,
1414
get_catalog_data,
1515
reset_catalog_cache_for_tests,
16+
reset_fixture_root_for_tests,
17+
resolve_fixture_root,
18+
set_fixture_root_for_tests,
1619
)
1720

1821
FIXTURE_PATH = (
@@ -23,7 +26,13 @@
2326
@pytest.fixture(autouse=True)
2427
def _reset_cache(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
2528
reset_catalog_cache_for_tests()
29+
reset_fixture_root_for_tests()
2630
monkeypatch.setenv("CPA_CACHE_DIR", str(tmp_path / "cache"))
31+
monkeypatch.delenv("CPA_FIXTURE_DIR", raising=False)
32+
monkeypatch.delenv("CPA_CATALOG_FIXTURE", raising=False)
33+
yield
34+
reset_fixture_root_for_tests()
35+
reset_catalog_cache_for_tests()
2736

2837

2938
def test_default_catalog_url(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -83,3 +92,35 @@ def test_get_catalog_data_disk_fallback_on_network_error(
8392

8493
def test_default_url_points_to_cpa_templates() -> None:
8594
assert DEFAULT_CATALOG_URL.endswith("/cpa-templates/main/templates.json")
95+
96+
97+
def test_resolve_fixture_root_respects_env(
98+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
99+
) -> None:
100+
monkeypatch.setenv("CPA_FIXTURE_DIR", str(tmp_path))
101+
assert resolve_fixture_root() == tmp_path.resolve()
102+
103+
104+
def test_get_catalog_data_fixture_uses_custom_dir(
105+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
106+
) -> None:
107+
catalog_dir = tmp_path / "fixtures" / "catalog"
108+
catalog_dir.mkdir(parents=True)
109+
payload = {
110+
"templates": [{"slug": "from-env-fixture"}],
111+
"extensions": [],
112+
"categories": [],
113+
}
114+
(catalog_dir / "templates.json").write_text(
115+
json.dumps(payload), encoding="utf-8"
116+
)
117+
monkeypatch.setenv("CPA_CATALOG_FIXTURE", "1")
118+
monkeypatch.setenv("CPA_FIXTURE_DIR", str(tmp_path))
119+
data = get_catalog_data(force_refresh=True)
120+
assert data["templates"][0]["slug"] == "from-env-fixture"
121+
122+
123+
def test_set_fixture_root_for_tests(tmp_path: Path) -> None:
124+
set_fixture_root_for_tests(tmp_path)
125+
assert resolve_fixture_root() == tmp_path
126+
reset_fixture_root_for_tests()

0 commit comments

Comments
 (0)