diff --git a/packages/create-awesome-python-app/src/create_awesome_python_app/catalog.py b/packages/create-awesome-python-app/src/create_awesome_python_app/catalog.py index 080e42c..28440fa 100644 --- a/packages/create-awesome-python-app/src/create_awesome_python_app/catalog.py +++ b/packages/create-awesome-python-app/src/create_awesome_python_app/catalog.py @@ -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 @@ -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": []} @@ -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 @@ -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() diff --git a/packages/create-awesome-python-app/src/create_awesome_python_app/cli.py b/packages/create-awesome-python-app/src/create_awesome_python_app/cli.py index bdc5204..de8b047 100644 --- a/packages/create-awesome-python-app/src/create_awesome_python_app/cli.py +++ b/packages/create-awesome-python-app/src/create_awesome_python_app/cli.py @@ -4,6 +4,7 @@ import asyncio import os +import sys from pathlib import Path from typing import Any @@ -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.", @@ -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"} @@ -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") @@ -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__) @@ -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 diff --git a/packages/create-awesome-python-app/tests/conftest.py b/packages/create-awesome-python-app/tests/conftest.py new file mode 100644 index 0000000..1b5676d --- /dev/null +++ b/packages/create-awesome-python-app/tests/conftest.py @@ -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() diff --git a/packages/create-awesome-python-app/tests/test_catalog_fetch.py b/packages/create-awesome-python-app/tests/test_catalog_fetch.py index 6b225f7..6eab6d8 100644 --- a/packages/create-awesome-python-app/tests/test_catalog_fetch.py +++ b/packages/create-awesome-python-app/tests/test_catalog_fetch.py @@ -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 = ( @@ -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: @@ -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() diff --git a/packages/create-awesome-python-app/tests/test_cli.py b/packages/create-awesome-python-app/tests/test_cli.py index d15be92..f1f53de 100644 --- a/packages/create-awesome-python-app/tests/test_cli.py +++ b/packages/create-awesome-python-app/tests/test_cli.py @@ -1,3 +1,4 @@ +import os from pathlib import Path import pytest @@ -6,16 +7,6 @@ from typer.testing import CliRunner runner = CliRunner() -_CPA_ENV_VARS = ("CPA_REFRESH", "CPA_NO_CATALOG_CACHE", "CPA_CACHE_DIR") - - -@pytest.fixture(autouse=True) -def _clean_cpa_env(monkeypatch): - for name in _CPA_ENV_VARS: - monkeypatch.delenv(name, raising=False) - yield - for name in _CPA_ENV_VARS: - monkeypatch.delenv(name, raising=False) def test_version() -> None: @@ -223,3 +214,76 @@ async def fake_check_for_latest_version(_package_name): assert result.exit_code == 2 assert "Incompatible extension combination" in text assert "saga" in text + + +def test_help_mentions_fixture() -> None: + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + text = (result.stdout or "") + (result.stderr or "") + assert "fixture" in text.lower() + + +def test_preprocess_fixture_argv_bare_and_with_dir() -> None: + from create_awesome_python_app.cli import _FIXTURE_AUTO, _preprocess_fixture_argv + + assert _preprocess_fixture_argv(["cpa", "--fixture", "--list-templates"]) == [ + "cpa", + f"--fixture={_FIXTURE_AUTO}", + "--list-templates", + ] + assert _preprocess_fixture_argv( + ["cpa", "--fixture", "./my-fixtures", "--list-templates"] + ) == ["cpa", "--fixture", "./my-fixtures", "--list-templates"] + + +def test_fixture_flag_enables_catalog_fixture( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from create_awesome_python_app.cli import _FIXTURE_AUTO, apply_fixture_mode + + apply_fixture_mode(_FIXTURE_AUTO) + assert os.environ.get("CPA_CATALOG_FIXTURE") == "1" + assert "CPA_FIXTURE_DIR" not in os.environ + + monkeypatch.delenv("CPA_CATALOG_FIXTURE", raising=False) + monkeypatch.delenv("CPA_FIXTURE_DIR", raising=False) + apply_fixture_mode(str(tmp_path)) + assert os.environ.get("CPA_CATALOG_FIXTURE") == "1" + assert os.environ.get("CPA_FIXTURE_DIR") == str(tmp_path) + + +def test_list_templates_with_fixture_dir( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import json + import os + + catalog_dir = tmp_path / "fixtures" / "catalog" + catalog_dir.mkdir(parents=True) + (catalog_dir / "templates.json").write_text( + json.dumps( + { + "templates": [ + { + "slug": "fixture-only", + "category": "tooling", + "type": "cli", + } + ], + "extensions": [], + "categories": [], + } + ), + encoding="utf-8", + ) + monkeypatch.delenv("CPA_CATALOG_FIXTURE", raising=False) + monkeypatch.delenv("CPA_FIXTURE_DIR", raising=False) + + result = runner.invoke( + app, + ["--fixture", str(tmp_path), "--list-templates"], + ) + text = (result.stdout or "") + (result.stderr or "") + assert result.exit_code == 0, text + assert "fixture-only" in text + assert os.environ.get("CPA_CATALOG_FIXTURE") == "1" diff --git a/packages/create-awesome-python-app/tests/test_cpa_templates_integration.py b/packages/create-awesome-python-app/tests/test_cpa_templates_integration.py index 774db7f..620e9b8 100644 --- a/packages/create-awesome-python-app/tests/test_cpa_templates_integration.py +++ b/packages/create-awesome-python-app/tests/test_cpa_templates_integration.py @@ -21,6 +21,19 @@ def _cpa_templates_available() -> bool: return (FASTAPI_TEMPLATE / "pyproject.toml").is_file() +def _clean_fixture_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Ensure subprocess scaffolds are not forced into fixture mode.""" + monkeypatch.delenv("CPA_CATALOG_FIXTURE", raising=False) + monkeypatch.delenv("CPA_FIXTURE_DIR", raising=False) + + +def _subprocess_env() -> dict[str, str]: + env = os.environ.copy() + env.pop("CPA_CATALOG_FIXTURE", None) + env.pop("CPA_FIXTURE_DIR", None) + return env + + @pytest.mark.skipif( not _cpa_templates_available(), reason="cpa-templates checkout not available (set CPA_TEMPLATES_ROOT)", @@ -28,6 +41,7 @@ def _cpa_templates_available() -> bool: def test_scaffold_fastapi_starter_from_cpa_templates( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: + _clean_fixture_env(monkeypatch) monkeypatch.setenv("CI", "1") monkeypatch.setenv("CPA_SKIP_GIT", "1") monkeypatch.setenv("CPA_CACHE_DIR", str(tmp_path / "cpa-cache")) @@ -48,6 +62,7 @@ def test_scaffold_fastapi_starter_from_cpa_templates( cwd=_REPO_ROOT, capture_output=True, text=True, + env=_subprocess_env(), check=False, ) assert result.returncode == 0, result.stdout + result.stderr @@ -78,6 +93,7 @@ def test_scaffold_fastapi_starter_via_catalog_slug( """Scaffold using --template fastapi-starter slug (issue #160 / #161).""" import json + _clean_fixture_env(monkeypatch) monkeypatch.setenv("CI", "1") monkeypatch.setenv("CPA_SKIP_GIT", "1") monkeypatch.setenv("CPA_CACHE_DIR", str(tmp_path / "cpa-cache")) @@ -114,6 +130,7 @@ def test_scaffold_fastapi_starter_via_catalog_slug( cwd=_REPO_ROOT, capture_output=True, text=True, + env=_subprocess_env(), check=False, ) assert result.returncode == 0, result.stdout + result.stderr @@ -129,6 +146,7 @@ def test_scaffold_via_catalog_addon_slug( ) -> None: import json + _clean_fixture_env(monkeypatch) monkeypatch.setenv("CI", "1") monkeypatch.setenv("CPA_SKIP_GIT", "1") monkeypatch.setenv("CPA_CACHE_DIR", str(tmp_path / "cpa-cache")) @@ -170,6 +188,7 @@ def test_scaffold_via_catalog_addon_slug( cwd=_REPO_ROOT, capture_output=True, text=True, + env=_subprocess_env(), check=False, ) assert result.returncode == 0, result.stdout + result.stderr @@ -183,6 +202,7 @@ def test_scaffold_via_catalog_addon_slug( def test_scaffold_fastapi_with_github_setup_extension( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: + _clean_fixture_env(monkeypatch) monkeypatch.setenv("CI", "1") monkeypatch.setenv("CPA_SKIP_GIT", "1") monkeypatch.setenv("CPA_CACHE_DIR", str(tmp_path / "cpa-cache")) @@ -204,6 +224,7 @@ def test_scaffold_fastapi_with_github_setup_extension( cwd=_REPO_ROOT, capture_output=True, text=True, + env=_subprocess_env(), check=False, ) assert result.returncode == 0, result.stdout + result.stderr