Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -344,9 +344,9 @@ def group_extension_choices(
FETCH_TIMEOUT_SECONDS = 10
USER_AGENT = f"create-awesome-python-app/{__version__} (https://github.com/Create-Python-App/create-python-app)"

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

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


def resolve_fixture_root() -> Path | None:
"""Resolve the repo root that contains ``fixtures/catalog/templates.json``.

Priority: ``CPA_FIXTURE_DIR`` → walk-up from package → ``cwd``.
"""
if _fixture_root_override is not _SENTINEL:
return _fixture_root_override # type: ignore[return-value]

env = os.environ.get("CPA_FIXTURE_DIR", "").strip()
if env:
return Path(env).expanduser().resolve()

def _has_fixture_catalog(root: Path) -> bool:
return (root / "fixtures" / "catalog" / "templates.json").is_file()

if _has_fixture_catalog(_AUTO_FIXTURE_DIR):
return _AUTO_FIXTURE_DIR

# Editable / site-packages layouts vary; walk up from this file.
for parent in Path(__file__).resolve().parents:
if _has_fixture_catalog(parent):
return parent

cwd = Path.cwd()
if _has_fixture_catalog(cwd):
return cwd
return None


def set_fixture_root_for_tests(root: Path | None) -> None:
"""Override fixture root (test helper)."""
global _fixture_root_override
_fixture_root_override = root


def reset_fixture_root_for_tests() -> None:
global _fixture_root_override
_fixture_root_override = _SENTINEL


def fixture_catalog_path() -> Path | None:
root = resolve_fixture_root()
if root is None:
return None
return root / "fixtures" / "catalog" / "templates.json"


def _read_json_file(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))


def _read_fixture() -> dict[str, Any]:
if _FIXTURE.is_file():
return _read_json_file(_FIXTURE)
path = fixture_catalog_path()
if path is not None and path.is_file():
return _read_json_file(path)
return {"templates": [], "extensions": [], "categories": []}


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

if os.environ.get("CPA_CATALOG_FIXTURE") == "1":
path = fixture_catalog_path()
if path is None or not path.is_file():
raise RuntimeError(
"Fixture mode is enabled (CPA_CATALOG_FIXTURE=1) but the fixture "
"root could not be resolved. Set CPA_FIXTURE_DIR to the repo root "
"containing fixtures/catalog/templates.json."
)
data = _read_fixture()
_memory_cache = data
_memory_ts = time.time()
return data

if (
not force_refresh
and _memory_cache is not None
Expand All@@ -425,38 +486,33 @@ def get_catalog_data(*, force_refresh: bool = False) -> dict[str, Any]:
):
return _memory_cache

if os.environ.get("CPA_CATALOG_FIXTURE") == "1":
data = _read_fixture()
else:
url = catalog_url()
try:
data = _fetch_remote(url)
_write_disk_cache(data)
except (
urllib.error.URLError,
TimeoutError,
OSError,
json.JSONDecodeError,
) as err:
disk = _read_disk_cache()
if disk is not None:
url = catalog_url()
try:
data = _fetch_remote(url)
_write_disk_cache(data)
except (
urllib.error.URLError,
TimeoutError,
OSError,
json.JSONDecodeError,
) as err:
disk = _read_disk_cache()
if disk is not None:
console.print(
"[yellow][cpa] Could not refresh catalog "
f"({err}); using disk cache.[/yellow]"
)
data = disk
else:
fixture = _read_fixture()
if fixture.get("templates"):
console.print(
"[yellow][cpa] Could not refresh catalog "
f"({err}); using disk cache.[/yellow]"
f"({err}); using fixture.[/yellow]"
)
data = disk
data = fixture
else:
fixture = _read_fixture()
if fixture.get("templates"):
console.print(
"[yellow][cpa] Could not refresh catalog "
f"({err}); using fixture.[/yellow]"
)
data = fixture
else:
raise RuntimeError(
f"Failed to load template catalog: {err}"
) from err
raise RuntimeError(f"Failed to load template catalog: {err}") from err

_memory_cache = data
_memory_ts = time.time()
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@

import asyncio
import os
import sys
from pathlib import Path
from typing import Any

Expand All@@ -25,6 +26,9 @@

from create_awesome_python_app import __version__

# Sentinel for bare ``--fixture`` (optional DIR rewritten in argv preprocess).
_FIXTURE_AUTO = "__CPA_FIXTURE_AUTO__"

app = typer.Typer(
name="create-awesome-python-app",
help="Composable scaffolding CLI for production-ready Python apps.",
Expand All@@ -36,6 +40,43 @@
console = Console(stderr=True)


def _preprocess_fixture_argv(argv: list[str] | None = None) -> list[str]:
"""Rewrite bare ``--fixture`` to ``--fixture=__CPA_FIXTURE_AUTO__``.

Typer/Click requires an option argument; Commander allows ``--fixture [dir]``.
This keeps CNA-compatible UX: ``--fixture`` alone enables auto-detect mode.
"""
raw = list(sys.argv if argv is None else argv)
if not raw:
return raw
out = [raw[0]]
i = 1
while i < len(raw):
arg = raw[i]
if arg == "--fixture":
if i + 1 < len(raw) and not raw[i + 1].startswith("-"):
out.extend(["--fixture", raw[i + 1]])
i += 2
else:
out.append(f"--fixture={_FIXTURE_AUTO}")
i += 1
continue
out.append(arg)
i += 1
if argv is None:
sys.argv = out
return out


def apply_fixture_mode(fixture: str | None) -> None:
"""Translate ``--fixture`` into ``CPA_CATALOG_FIXTURE`` / ``CPA_FIXTURE_DIR``."""
if fixture is None and os.environ.get("CPA_CATALOG_FIXTURE") != "1":
return
os.environ["CPA_CATALOG_FIXTURE"] = "1"
if fixture is not None and fixture != _FIXTURE_AUTO and fixture != "":
os.environ["CPA_FIXTURE_DIR"] = fixture


def _in_ci() -> bool:
return os.environ.get("CI", "").lower() in {"1", "true", "yes"}

Expand DownExpand Up@@ -159,9 +200,8 @@ def main() -> None:
`create-awesome-python-app cache dir` works (Typer would otherwise
treat `cache` as project_directory).
"""
import sys

check_python_version(">=3.12", "create-awesome-python-app")
_preprocess_fixture_argv()
if len(sys.argv) > 1 and sys.argv[1] == "cache":
sys.argv = [sys.argv[0], *sys.argv[2:]]
cache_app(prog_name="create-awesome-python-app cache")
Expand DownExpand Up@@ -192,6 +232,15 @@ def scaffold(
refresh: str | None = typer.Option(None, "--refresh"),
strict_version: bool = typer.Option(False, "--strict-version"),
keep_on_failure: bool = typer.Option(False, "--keep-on-failure"),
fixture: str | None = typer.Option(
None,
"--fixture",
help=(
"Load the template catalog from the local fixtures/ directory "
"instead of the network (optional DIR = repo root; also "
"CPA_FIXTURE_DIR / CPA_CATALOG_FIXTURE)"
),
),
) -> None:
if version:
console.print(__version__)
Expand All@@ -201,6 +250,10 @@ def scaffold(
if ctx.invoked_subcommand is not None:
return

# Translate --fixture into env vars before catalog loads
# (--list-templates / interactive / scaffold).
apply_fixture_mode(fixture)

if list_templates or list_addons:
from create_awesome_python_app.catalog import list_addons as la
from create_awesome_python_app.catalog import list_templates as lt
Expand Down
39 changes: 39 additions & 0 deletions packages/create-awesome-python-app/tests/conftest.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
"""Shared test fixtures for create-awesome-python-app."""

from __future__ import annotations

import os

import pytest

_CPA_ENV_VARS = (
"CPA_REFRESH",
"CPA_NO_CATALOG_CACHE",
"CPA_CACHE_DIR",
"CPA_CATALOG_FIXTURE",
"CPA_FIXTURE_DIR",
)


@pytest.fixture(autouse=True)
def _clean_cpa_process_env():
"""Clear CPA env vars that CLI helpers set via ``os.environ`` (not monkeypatch).

``apply_fixture_mode`` mutates ``os.environ`` directly. Pairing that with
``monkeypatch.delenv`` after the test can restore the leaked value when
monkeypatch undoes its stack — so cleanup must use ``os.environ.pop``.
"""
from create_awesome_python_app.catalog import (
reset_catalog_cache_for_tests,
reset_fixture_root_for_tests,
)

for name in _CPA_ENV_VARS:
os.environ.pop(name, None)
reset_catalog_cache_for_tests()
reset_fixture_root_for_tests()
yield
for name in _CPA_ENV_VARS:
os.environ.pop(name, None)
reset_catalog_cache_for_tests()
reset_fixture_root_for_tests()
41 changes: 40 additions & 1 deletion packages/create-awesome-python-app/tests/test_catalog_fetch.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,9 @@
catalog_url,
get_catalog_data,
reset_catalog_cache_for_tests,
reset_fixture_root_for_tests,
resolve_fixture_root,
set_fixture_root_for_tests,
)

FIXTURE_PATH = (
Expand All@@ -21,9 +24,15 @@


@pytest.fixture(autouse=True)
def _reset_cache(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
def _reset_cache(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
reset_catalog_cache_for_tests()
reset_fixture_root_for_tests()
monkeypatch.setenv("CPA_CACHE_DIR", str(tmp_path / "cache"))
monkeypatch.delenv("CPA_FIXTURE_DIR", raising=False)
monkeypatch.delenv("CPA_CATALOG_FIXTURE", raising=False)
yield
reset_fixture_root_for_tests()
reset_catalog_cache_for_tests()


def test_default_catalog_url(monkeypatch: pytest.MonkeyPatch) -> None:
Expand DownExpand Up@@ -83,3 +92,33 @@ def test_get_catalog_data_disk_fallback_on_network_error(

def test_default_url_points_to_cpa_templates() -> None:
assert DEFAULT_CATALOG_URL.endswith("/cpa-templates/main/templates.json")


def test_resolve_fixture_root_respects_env(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("CPA_FIXTURE_DIR", str(tmp_path))
assert resolve_fixture_root() == tmp_path.resolve()


def test_get_catalog_data_fixture_uses_custom_dir(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
catalog_dir = tmp_path / "fixtures" / "catalog"
catalog_dir.mkdir(parents=True)
payload = {
"templates": [{"slug": "from-env-fixture"}],
"extensions": [],
"categories": [],
}
(catalog_dir / "templates.json").write_text(json.dumps(payload), encoding="utf-8")
monkeypatch.setenv("CPA_CATALOG_FIXTURE", "1")
monkeypatch.setenv("CPA_FIXTURE_DIR", str(tmp_path))
data = get_catalog_data(force_refresh=True)
assert data["templates"][0]["slug"] == "from-env-fixture"


def test_set_fixture_root_for_tests(tmp_path: Path) -> None:
set_fixture_root_for_tests(tmp_path)
assert resolve_fixture_root() == tmp_path
reset_fixture_root_for_tests()
Loading
Loading