Skip to content

Commit 2bd780e

Browse files
Merge pull request #235 from Create-Python-App/feat/issue-227-fixture-flag
feat(cli): add --fixture flag for catalog fixture mode
2 parents 6012c89 + 01b1e81 commit 2bd780e

6 files changed

Lines changed: 318 additions & 46 deletions

File tree

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

Lines changed: 89 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,61 @@ 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`` → walk-up from package → ``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+
def _has_fixture_catalog(root: Path) -> bool:
376+
return (root / "fixtures" / "catalog" / "templates.json").is_file()
377+
378+
if _has_fixture_catalog(_AUTO_FIXTURE_DIR):
379+
return _AUTO_FIXTURE_DIR
380+
381+
# Editable / site-packages layouts vary; walk up from this file.
382+
for parent in Path(__file__).resolve().parents:
383+
if _has_fixture_catalog(parent):
384+
return parent
385+
386+
cwd = Path.cwd()
387+
if _has_fixture_catalog(cwd):
388+
return cwd
389+
return None
390+
391+
392+
def set_fixture_root_for_tests(root: Path | None) -> None:
393+
"""Override fixture root (test helper)."""
394+
global _fixture_root_override
395+
_fixture_root_override = root
396+
397+
398+
def reset_fixture_root_for_tests() -> None:
399+
global _fixture_root_override
400+
_fixture_root_override = _SENTINEL
401+
402+
403+
def fixture_catalog_path() -> Path | None:
404+
root = resolve_fixture_root()
405+
if root is None:
406+
return None
407+
return root / "fixtures" / "catalog" / "templates.json"
408+
409+
363410
def _read_json_file(path: Path) -> dict[str, Any]:
364411
return json.loads(path.read_text(encoding="utf-8"))
365412

366413

367414
def _read_fixture() -> dict[str, Any]:
368-
if _FIXTURE.is_file():
369-
return _read_json_file(_FIXTURE)
415+
path = fixture_catalog_path()
416+
if path is not None and path.is_file():
417+
return _read_json_file(path)
370418
return {"templates": [], "extensions": [], "categories": []}
371419

372420

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

468+
if os.environ.get("CPA_CATALOG_FIXTURE") == "1":
469+
path = fixture_catalog_path()
470+
if path is None or not path.is_file():
471+
raise RuntimeError(
472+
"Fixture mode is enabled (CPA_CATALOG_FIXTURE=1) but the fixture "
473+
"root could not be resolved. Set CPA_FIXTURE_DIR to the repo root "
474+
"containing fixtures/catalog/templates.json."
475+
)
476+
data = _read_fixture()
477+
_memory_cache = data
478+
_memory_ts = time.time()
479+
return data
480+
420481
if (
421482
not force_refresh
422483
and _memory_cache is not None
@@ -425,38 +486,33 @@ def get_catalog_data(*, force_refresh: bool = False) -> dict[str, Any]:
425486
):
426487
return _memory_cache
427488

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:
489+
url = catalog_url()
490+
try:
491+
data = _fetch_remote(url)
492+
_write_disk_cache(data)
493+
except (
494+
urllib.error.URLError,
495+
TimeoutError,
496+
OSError,
497+
json.JSONDecodeError,
498+
) as err:
499+
disk = _read_disk_cache()
500+
if disk is not None:
501+
console.print(
502+
"[yellow][cpa] Could not refresh catalog "
503+
f"({err}); using disk cache.[/yellow]"
504+
)
505+
data = disk
506+
else:
507+
fixture = _read_fixture()
508+
if fixture.get("templates"):
443509
console.print(
444510
"[yellow][cpa] Could not refresh catalog "
445-
f"({err}); using disk cache.[/yellow]"
511+
f"({err}); using fixture.[/yellow]"
446512
)
447-
data = disk
513+
data = fixture
448514
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
515+
raise RuntimeError(f"Failed to load template catalog: {err}") from err
460516

461517
_memory_cache = data
462518
_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
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Shared test fixtures for create-awesome-python-app."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
7+
import pytest
8+
9+
_CPA_ENV_VARS = (
10+
"CPA_REFRESH",
11+
"CPA_NO_CATALOG_CACHE",
12+
"CPA_CACHE_DIR",
13+
"CPA_CATALOG_FIXTURE",
14+
"CPA_FIXTURE_DIR",
15+
)
16+
17+
18+
@pytest.fixture(autouse=True)
19+
def _clean_cpa_process_env():
20+
"""Clear CPA env vars that CLI helpers set via ``os.environ`` (not monkeypatch).
21+
22+
``apply_fixture_mode`` mutates ``os.environ`` directly. Pairing that with
23+
``monkeypatch.delenv`` after the test can restore the leaked value when
24+
monkeypatch undoes its stack — so cleanup must use ``os.environ.pop``.
25+
"""
26+
from create_awesome_python_app.catalog import (
27+
reset_catalog_cache_for_tests,
28+
reset_fixture_root_for_tests,
29+
)
30+
31+
for name in _CPA_ENV_VARS:
32+
os.environ.pop(name, None)
33+
reset_catalog_cache_for_tests()
34+
reset_fixture_root_for_tests()
35+
yield
36+
for name in _CPA_ENV_VARS:
37+
os.environ.pop(name, None)
38+
reset_catalog_cache_for_tests()
39+
reset_fixture_root_for_tests()

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

Lines changed: 40 additions & 1 deletion
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 = (
@@ -21,9 +24,15 @@
2124

2225

2326
@pytest.fixture(autouse=True)
24-
def _reset_cache(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
27+
def _reset_cache(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
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,33 @@ 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(json.dumps(payload), encoding="utf-8")
115+
monkeypatch.setenv("CPA_CATALOG_FIXTURE", "1")
116+
monkeypatch.setenv("CPA_FIXTURE_DIR", str(tmp_path))
117+
data = get_catalog_data(force_refresh=True)
118+
assert data["templates"][0]["slug"] == "from-env-fixture"
119+
120+
121+
def test_set_fixture_root_for_tests(tmp_path: Path) -> None:
122+
set_fixture_root_for_tests(tmp_path)
123+
assert resolve_fixture_root() == tmp_path
124+
reset_fixture_root_for_tests()

0 commit comments

Comments
 (0)