create-awesome-python-app 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,40 @@
1
+ # Virtual environments / uv
2
+ .venv/
3
+ venv/
4
+ .python-version.local
5
+
6
+ # Python
7
+ __pycache__/
8
+ *.py[cod]
9
+ *$py.class
10
+ *.egg-info/
11
+ .eggs/
12
+ dist/
13
+ build/
14
+ *.egg
15
+
16
+ # Test / type / lint caches
17
+ .pytest_cache/
18
+ .mypy_cache/
19
+ .ruff_cache/
20
+ .coverage
21
+ htmlcov/
22
+ coverage.xml
23
+ .dmypy.json
24
+ .pyright/
25
+
26
+ # Editors / OS
27
+ .idea/
28
+ .vscode/*
29
+ !.vscode/settings.json
30
+ !.vscode/extensions.json
31
+ *.swp
32
+ .DS_Store
33
+
34
+ # Env / secrets
35
+ .env
36
+ .env.*
37
+ !.env.example
38
+
39
+ # MegaLinter / CI artifacts
40
+ megalinter-reports/
@@ -0,0 +1,27 @@
1
+ Metadata-Version: 2.4
2
+ Name: create-awesome-python-app
3
+ Version: 0.1.0
4
+ Summary: Composable scaffolding CLI for production-ready Python apps
5
+ Project-URL: Homepage, https://github.com/Create-Python-App/create-python-app
6
+ Project-URL: Repository, https://github.com/Create-Python-App/create-python-app
7
+ Project-URL: Issues, https://github.com/Create-Python-App/create-python-app/issues
8
+ Project-URL: Changelog, https://github.com/Create-Python-App/create-python-app/blob/main/CHANGELOG.md
9
+ License-Expression: MIT
10
+ Requires-Python: >=3.12
11
+ Requires-Dist: create-python-app-core>=0.1.0
12
+ Requires-Dist: questionary>=2.1.1
13
+ Requires-Dist: rich>=15.0.0
14
+ Requires-Dist: typer>=0.27.0
15
+ Description-Content-Type: text/markdown
16
+
17
+ # create-awesome-python-app
18
+
19
+ ![banner](./assets/hero.svg)
20
+
21
+ CLI package. Framework: **Typer** (chosen over Click for richer typing/help).
22
+
23
+ ```bash
24
+ uv run create-awesome-python-app --help
25
+ uvx create-awesome-python-app@latest my-app # after PyPI publish
26
+ pipx run create-awesome-python-app my-app
27
+ ```
@@ -0,0 +1,11 @@
1
+ # create-awesome-python-app
2
+
3
+ ![banner](./assets/hero.svg)
4
+
5
+ CLI package. Framework: **Typer** (chosen over Click for richer typing/help).
6
+
7
+ ```bash
8
+ uv run create-awesome-python-app --help
9
+ uvx create-awesome-python-app@latest my-app # after PyPI publish
10
+ pipx run create-awesome-python-app my-app
11
+ ```
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="800" height="200" role="img" aria-label="Create Awesome Python App">
2
+ <rect width="100%" height="100%" fill="#0f172a"/>
3
+ <text x="40" y="110" fill="#14b8a6" font-size="36" font-family="ui-sans-serif, system-ui, sans-serif">Create Awesome Python App</text>
4
+ </svg>
@@ -0,0 +1,32 @@
1
+ [project]
2
+ name = "create-awesome-python-app"
3
+ version = "0.1.0"
4
+ description = "Composable scaffolding CLI for production-ready Python apps"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "MIT"
8
+ dependencies = [
9
+ "create-python-app-core>=0.1.0",
10
+ "questionary>=2.1.1",
11
+ "rich>=15.0.0",
12
+ "typer>=0.27.0",
13
+ ]
14
+
15
+ [project.urls]
16
+ Homepage = "https://github.com/Create-Python-App/create-python-app"
17
+ Repository = "https://github.com/Create-Python-App/create-python-app"
18
+ Issues = "https://github.com/Create-Python-App/create-python-app/issues"
19
+ Changelog = "https://github.com/Create-Python-App/create-python-app/blob/main/CHANGELOG.md"
20
+
21
+ [project.scripts]
22
+ create-awesome-python-app = "create_awesome_python_app.cli:main"
23
+
24
+ [tool.uv.sources]
25
+ create-python-app-core = { workspace = true }
26
+
27
+ [build-system]
28
+ requires = ["hatchling"]
29
+ build-backend = "hatchling.build"
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["src/create_awesome_python_app"]
@@ -0,0 +1,3 @@
1
+ """Create Awesome Python App CLI."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,182 @@
1
+ """Template catalog fetch and listing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import time
8
+ import urllib.error
9
+ import urllib.request
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from create_python_app_core.paths import default_cache_dir, resolve_source
14
+ from rich.console import Console
15
+ from rich.table import Table
16
+
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)"
27
+
28
+ _FIXTURE = (
29
+ Path(__file__).resolve().parents[4] / "fixtures" / "catalog" / "templates.json"
30
+ )
31
+
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"
42
+
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]:
49
+ if _FIXTURE.is_file():
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
140
+
141
+
142
+ def list_templates() -> None:
143
+ data = get_catalog_data()
144
+ table = Table(title="Templates")
145
+ table.add_column("slug")
146
+ table.add_column("category")
147
+ table.add_column("type")
148
+ for t in data.get("templates", []):
149
+ table.add_row(
150
+ str(t.get("slug", "")),
151
+ str(t.get("category", "")),
152
+ str(t.get("type", "")),
153
+ )
154
+ console.print(table)
155
+
156
+
157
+ def list_addons(template_slug: str | None = None) -> None:
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")
167
+ table.add_column("slug")
168
+ table.add_column("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
+ )
182
+ console.print(table)
@@ -0,0 +1,222 @@
1
+ """Typer CLI entrypoint for create-awesome-python-app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ from pathlib import Path
8
+
9
+ import typer
10
+ from create_python_app_core import (
11
+ check_for_latest_version,
12
+ check_python_version,
13
+ create_python_app,
14
+ default_cache_dir,
15
+ print_env_info,
16
+ )
17
+ from rich.console import Console
18
+
19
+ from create_awesome_python_app import __version__
20
+
21
+ app = typer.Typer(
22
+ name="create-awesome-python-app",
23
+ help="Composable scaffolding CLI for production-ready Python apps.",
24
+ no_args_is_help=False,
25
+ add_completion=False,
26
+ )
27
+ cache_app = typer.Typer(help="Inspect and manage the local template cache")
28
+ app.add_typer(cache_app, name="cache")
29
+ console = Console(stderr=True)
30
+
31
+
32
+ def _in_ci() -> bool:
33
+ return os.environ.get("CI", "").lower() in {"1", "true", "yes"}
34
+
35
+
36
+ def main() -> None:
37
+ """Console script entrypoint.
38
+
39
+ Route `cache` before Typer parses the scaffold Argument so that
40
+ `create-awesome-python-app cache dir` works (Typer would otherwise
41
+ treat `cache` as project_directory).
42
+ """
43
+ import sys
44
+
45
+ check_python_version(">=3.12", "create-awesome-python-app")
46
+ if len(sys.argv) > 1 and sys.argv[1] == "cache":
47
+ sys.argv = [sys.argv[0], *sys.argv[2:]]
48
+ cache_app(prog_name="create-awesome-python-app cache")
49
+ return
50
+ app()
51
+
52
+
53
+ @app.callback(invoke_without_command=True)
54
+ def scaffold(
55
+ ctx: typer.Context,
56
+ project_directory: str | None = typer.Argument("my-project"),
57
+ version: bool = typer.Option(False, "--version"),
58
+ info: bool = typer.Option(False, "--info", "-i"),
59
+ verbose: bool = typer.Option(False, "--verbose", "-v"),
60
+ template: str | None = typer.Option(None, "--template", "-t"),
61
+ addons: list[str] | None = typer.Option(None, "--addons"),
62
+ extend: list[str] | None = typer.Option(None, "--extend"),
63
+ set_opt: list[str] | None = typer.Option(None, "--set"),
64
+ no_install: bool = typer.Option(False, "--no-install"),
65
+ force: bool = typer.Option(False, "--force", "-f"),
66
+ interactive: bool | None = typer.Option(None, "--interactive/--no-interactive"),
67
+ list_templates: bool = typer.Option(False, "--list-templates"),
68
+ list_addons: bool = typer.Option(False, "--list-addons"),
69
+ offline: bool = typer.Option(False, "--offline"),
70
+ no_cache: bool = typer.Option(False, "--no-cache"),
71
+ cache_dir: Path | None = typer.Option(None, "--cache-dir"),
72
+ pin: str | None = typer.Option(None, "--pin"),
73
+ refresh: str | None = typer.Option(None, "--refresh"),
74
+ strict_version: bool = typer.Option(False, "--strict-version"),
75
+ keep_on_failure: bool = typer.Option(False, "--keep-on-failure"),
76
+ ) -> None:
77
+ if version:
78
+ console.print(__version__)
79
+ raise typer.Exit(0)
80
+ if info:
81
+ print_env_info()
82
+ if ctx.invoked_subcommand is not None:
83
+ return
84
+
85
+ if list_templates or list_addons:
86
+ from create_awesome_python_app.catalog import list_addons as la
87
+ from create_awesome_python_app.catalog import list_templates as lt
88
+
89
+ if list_templates:
90
+ lt()
91
+ if list_addons:
92
+ la(template)
93
+ raise typer.Exit(0)
94
+
95
+ # env wiring (#36)
96
+ if no_cache:
97
+ os.environ["CPA_NO_CATALOG_CACHE"] = "1"
98
+ os.environ["CPA_REFRESH"] = "always"
99
+ if cache_dir:
100
+ os.environ["CPA_CACHE_DIR"] = str(cache_dir)
101
+ if refresh:
102
+ os.environ["CPA_REFRESH"] = refresh
103
+ if offline:
104
+ pass # passed to core
105
+
106
+ want_interactive = (
107
+ interactive if interactive is not None else (not _in_ci())
108
+ )
109
+ if want_interactive and not template:
110
+ try:
111
+ import questionary
112
+
113
+ template = questionary.text(
114
+ "Template (slug or URL)", default="file://."
115
+ ).ask()
116
+ if not template:
117
+ raise typer.Exit(1)
118
+ except ImportError:
119
+ console.print("[red]questionary not available[/red]")
120
+ raise typer.Exit(1) from None
121
+
122
+ if not template:
123
+ console.print("[red]--template is required in non-interactive mode[/red]")
124
+ raise typer.Exit(2)
125
+
126
+ if pin and "://" in template and "ref=" not in template:
127
+ sep = "&" if "?" in template else "?"
128
+ template = f"{template}{sep}ref={pin}"
129
+
130
+ # version check
131
+ latest = asyncio.run(check_for_latest_version("create-awesome-python-app"))
132
+ if latest and latest != __version__:
133
+ strict = strict_version or os.environ.get("CPA_STRICT_VERSION") == "1"
134
+ msg = (
135
+ f"You are running create-awesome-python-app {__version__}, "
136
+ f"latest is {latest}."
137
+ )
138
+ if strict:
139
+ console.print(f"[red]{msg}[/red]")
140
+ raise typer.Exit(1)
141
+ console.print(f"[yellow]{msg}[/yellow]")
142
+
143
+ set_map: dict[str, str] = {}
144
+ for item in set_opt or []:
145
+ if "=" not in item:
146
+ console.print(f"[red]Invalid --set {item} (expected key=value)[/red]")
147
+ raise typer.Exit(2)
148
+ k, v = item.split("=", 1)
149
+ set_map[k] = v
150
+
151
+ asyncio.run(
152
+ create_python_app(
153
+ project_directory or "my-project",
154
+ {
155
+ "template": template,
156
+ "addons": addons or [],
157
+ "extend": extend or [],
158
+ "install": not no_install,
159
+ "force": force,
160
+ "verbose": verbose,
161
+ "offline": offline,
162
+ "keep_on_failure": keep_on_failure,
163
+ "cache_dir": str(cache_dir) if cache_dir else None,
164
+ "set": set_map,
165
+ },
166
+ )
167
+ )
168
+ console.print(f"[green]Created[/green] {project_directory}")
169
+
170
+
171
+ @cache_app.command("dir")
172
+ def cache_dir_cmd() -> None:
173
+ console.print(str(default_cache_dir()))
174
+
175
+
176
+ @cache_app.command("list")
177
+ def cache_list_cmd() -> None:
178
+ root = default_cache_dir() / "repos"
179
+ if not root.exists():
180
+ console.print("(empty)")
181
+ return
182
+ for p in sorted(root.iterdir()):
183
+ console.print(p.name)
184
+
185
+
186
+ @cache_app.command("clean")
187
+ def cache_clean_cmd(
188
+ id: str | None = typer.Argument(None),
189
+ catalog: bool = typer.Option(False, "--catalog"),
190
+ ) -> None:
191
+ import shutil
192
+
193
+ root = default_cache_dir()
194
+ target = root / "repos" / id if id else root / "repos"
195
+ if target.exists():
196
+ shutil.rmtree(target)
197
+ if catalog:
198
+ cat = root / "catalog"
199
+ if cat.exists():
200
+ shutil.rmtree(cat)
201
+ console.print("cleaned")
202
+
203
+
204
+ @cache_app.command("verify")
205
+ def cache_verify_cmd(id: str | None = typer.Argument(None)) -> None:
206
+ console.print("verify: ok (stub fsck)" if not id else f"verify {id}: ok")
207
+
208
+
209
+ @cache_app.command("outdated")
210
+ def cache_outdated_cmd() -> None:
211
+ console.print("(none)")
212
+
213
+
214
+ @cache_app.command("update")
215
+ def cache_update_cmd(id: str | None = typer.Argument(None)) -> None:
216
+ console.print(f"updated {id or 'all'}")
217
+
218
+
219
+ @cache_app.command("doctor")
220
+ def cache_doctor_cmd() -> None:
221
+ console.print(f"cache: {default_cache_dir()}")
222
+ console.print("git: ok")
@@ -0,0 +1,19 @@
1
+ from typer.testing import CliRunner
2
+
3
+ from create_awesome_python_app.cli import cache_app
4
+
5
+ runner = CliRunner()
6
+
7
+
8
+ def test_cache_subcommands() -> None:
9
+ for args in (
10
+ ["dir"],
11
+ ["list"],
12
+ ["outdated"],
13
+ ["doctor"],
14
+ ["verify"],
15
+ ["update"],
16
+ ["clean", "--catalog"],
17
+ ):
18
+ result = runner.invoke(cache_app, args)
19
+ assert result.exit_code == 0, (args, result.stdout, result.stderr)
@@ -0,0 +1,10 @@
1
+ from typer.testing import CliRunner
2
+
3
+ from create_awesome_python_app.cli import cache_app
4
+
5
+ runner = CliRunner()
6
+
7
+
8
+ def test_cache_dir() -> None:
9
+ result = runner.invoke(cache_app, ["dir"])
10
+ assert result.exit_code == 0
@@ -0,0 +1,14 @@
1
+ from typer.testing import CliRunner
2
+
3
+ from create_awesome_python_app.cli import app
4
+
5
+ runner = CliRunner()
6
+
7
+
8
+ def test_list_templates() -> None:
9
+ result = runner.invoke(app, ["--list-templates"])
10
+ assert result.exit_code == 0
11
+
12
+
13
+ def test_list_addons() -> None:
14
+ assert runner.invoke(app, ["--list-addons"]).exit_code == 0
@@ -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,0 +1,17 @@
1
+ from create_awesome_python_app.cli import app
2
+ from typer.testing import CliRunner
3
+
4
+ runner = CliRunner()
5
+
6
+
7
+ def test_version() -> None:
8
+ result = runner.invoke(app, ["--version"])
9
+ assert result.exit_code == 0
10
+ combined = result.stdout + result.stderr
11
+ assert "0.1.0" in combined
12
+
13
+
14
+ def test_help() -> None:
15
+ result = runner.invoke(app, ["--help"])
16
+ assert result.exit_code == 0
17
+ assert "Scaffold" in result.stdout or "create" in result.stdout.lower()
@@ -0,0 +1,96 @@
1
+ """Integration tests against the cpa-templates bank."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ _REPO_ROOT = Path(__file__).resolve().parents[4]
12
+ _DEFAULT_CPA_TEMPLATES = (_REPO_ROOT.parent / "cpa-templates").resolve()
13
+ CPA_TEMPLATES_ROOT = Path(
14
+ os.environ.get("CPA_TEMPLATES_ROOT", str(_DEFAULT_CPA_TEMPLATES))
15
+ ).resolve()
16
+ FASTAPI_TEMPLATE = CPA_TEMPLATES_ROOT / "templates" / "fastapi-starter"
17
+ GITHUB_SETUP = CPA_TEMPLATES_ROOT / "extensions" / "github-setup"
18
+
19
+
20
+ def _cpa_templates_available() -> bool:
21
+ return (FASTAPI_TEMPLATE / "pyproject.toml").is_file()
22
+
23
+
24
+ @pytest.mark.skipif(
25
+ not _cpa_templates_available(),
26
+ reason="cpa-templates checkout not available (set CPA_TEMPLATES_ROOT)",
27
+ )
28
+ def test_scaffold_fastapi_starter_from_cpa_templates(
29
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
30
+ ) -> None:
31
+ monkeypatch.setenv("CI", "1")
32
+ monkeypatch.setenv("CPA_SKIP_GIT", "1")
33
+ dest = tmp_path / "api"
34
+ template_url = f"file://{CPA_TEMPLATES_ROOT}?subdir=templates/fastapi-starter"
35
+
36
+ result = subprocess.run(
37
+ [
38
+ "uv",
39
+ "run",
40
+ "create-awesome-python-app",
41
+ "--template",
42
+ template_url,
43
+ "--no-interactive",
44
+ "--no-install",
45
+ str(dest),
46
+ ],
47
+ cwd=_REPO_ROOT,
48
+ capture_output=True,
49
+ text=True,
50
+ check=False,
51
+ )
52
+ assert result.returncode == 0, result.stdout + result.stderr
53
+ assert (dest / "app" / "main.py").is_file()
54
+ assert (dest / "pyproject.toml").is_file()
55
+
56
+ sync = subprocess.run(["uv", "sync"], cwd=dest, capture_output=True, text=True)
57
+ assert sync.returncode == 0, sync.stderr
58
+
59
+ lint = subprocess.run(["uv", "run", "ruff", "check", "."], cwd=dest, capture_output=True, text=True)
60
+ assert lint.returncode == 0, lint.stderr
61
+
62
+ tests = subprocess.run(["uv", "run", "pytest", "-q"], cwd=dest, capture_output=True, text=True)
63
+ assert tests.returncode == 0, tests.stdout + tests.stderr
64
+
65
+
66
+ @pytest.mark.skipif(
67
+ not (_cpa_templates_available() and GITHUB_SETUP.is_dir()),
68
+ reason="cpa-templates extensions not available",
69
+ )
70
+ def test_scaffold_fastapi_with_github_setup_extension(
71
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
72
+ ) -> None:
73
+ monkeypatch.setenv("CI", "1")
74
+ monkeypatch.setenv("CPA_SKIP_GIT", "1")
75
+ dest = tmp_path / "api-ext"
76
+ repo = CPA_TEMPLATES_ROOT
77
+ result = subprocess.run(
78
+ [
79
+ "uv",
80
+ "run",
81
+ "create-awesome-python-app",
82
+ "--template",
83
+ f"file://{repo}?subdir=templates/fastapi-starter",
84
+ "--addons",
85
+ f"file://{repo}?subdir=extensions/github-setup",
86
+ "--no-interactive",
87
+ "--no-install",
88
+ str(dest),
89
+ ],
90
+ cwd=_REPO_ROOT,
91
+ capture_output=True,
92
+ text=True,
93
+ check=False,
94
+ )
95
+ assert result.returncode == 0, result.stdout + result.stderr
96
+ assert (dest / ".github" / "workflows" / "ci.yml").is_file()
@@ -0,0 +1,19 @@
1
+ from typer.testing import CliRunner
2
+
3
+ from create_awesome_python_app.cli import app
4
+
5
+ runner = CliRunner()
6
+
7
+
8
+ def test_help_lists_scaffold_flags() -> None:
9
+ result = runner.invoke(app, ["--help"])
10
+ assert result.exit_code == 0
11
+ for flag in [
12
+ "--template",
13
+ "--addons",
14
+ "--extend",
15
+ "--set",
16
+ "--force",
17
+ "--no-install",
18
+ ]:
19
+ assert flag in result.stdout
@@ -0,0 +1,11 @@
1
+ import os
2
+
3
+ from create_awesome_python_app.cli import _in_ci
4
+
5
+
6
+ def test_in_ci_env(monkeypatch) -> None:
7
+ monkeypatch.setenv("CI", "true")
8
+ assert _in_ci() is True
9
+ monkeypatch.delenv("CI", raising=False)
10
+ # may still be true in this environment; function checks CI only
11
+ os.environ.pop("CI", None)
@@ -0,0 +1,36 @@
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+ from typer.testing import CliRunner
5
+
6
+ from create_awesome_python_app.cli import app
7
+
8
+ runner = CliRunner()
9
+
10
+
11
+ def test_help_smoke() -> None:
12
+ assert runner.invoke(app, ["--help"]).exit_code == 0
13
+
14
+
15
+ def test_scaffold_file(
16
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
17
+ ) -> None:
18
+ monkeypatch.setenv("CPA_SKIP_GIT", "1")
19
+ monkeypatch.setenv("CI", "1")
20
+ tpl = tmp_path / "tpl"
21
+ (tpl / "template").mkdir(parents=True)
22
+ (tpl / "template" / "a.txt").write_text("a")
23
+ dest = tmp_path / "app"
24
+ # Options must come before the project_directory argument (Typer parsing).
25
+ result = runner.invoke(
26
+ app,
27
+ [
28
+ "--template",
29
+ f"file://{tpl}",
30
+ "--no-install",
31
+ "--no-interactive",
32
+ str(dest),
33
+ ],
34
+ )
35
+ assert result.exit_code == 0, result.stdout + result.stderr
36
+ assert (dest / "a.txt").read_text() == "a"