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.
- create_awesome_python_app-0.1.0/.gitignore +40 -0
- create_awesome_python_app-0.1.0/PKG-INFO +27 -0
- create_awesome_python_app-0.1.0/README.md +11 -0
- create_awesome_python_app-0.1.0/assets/hero.svg +4 -0
- create_awesome_python_app-0.1.0/pyproject.toml +32 -0
- create_awesome_python_app-0.1.0/src/create_awesome_python_app/__init__.py +3 -0
- create_awesome_python_app-0.1.0/src/create_awesome_python_app/catalog.py +182 -0
- create_awesome_python_app-0.1.0/src/create_awesome_python_app/cli.py +222 -0
- create_awesome_python_app-0.1.0/tests/test_cache_cmds.py +19 -0
- create_awesome_python_app-0.1.0/tests/test_cache_env.py +10 -0
- create_awesome_python_app-0.1.0/tests/test_catalog.py +14 -0
- create_awesome_python_app-0.1.0/tests/test_catalog_fetch.py +86 -0
- create_awesome_python_app-0.1.0/tests/test_cli.py +17 -0
- create_awesome_python_app-0.1.0/tests/test_cpa_templates_integration.py +96 -0
- create_awesome_python_app-0.1.0/tests/test_flags.py +19 -0
- create_awesome_python_app-0.1.0/tests/test_interactive.py +11 -0
- create_awesome_python_app-0.1.0/tests/test_smoke.py +36 -0
|
@@ -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
|
+

|
|
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
|
+

|
|
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,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,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"
|