create-awesome-python-app 0.1.0__py3-none-any.whl
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/__init__.py +3 -0
- create_awesome_python_app/catalog.py +182 -0
- create_awesome_python_app/cli.py +222 -0
- create_awesome_python_app-0.1.0.dist-info/METADATA +27 -0
- create_awesome_python_app-0.1.0.dist-info/RECORD +7 -0
- create_awesome_python_app-0.1.0.dist-info/WHEEL +4 -0
- create_awesome_python_app-0.1.0.dist-info/entry_points.txt +2 -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,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,7 @@
|
|
|
1
|
+
create_awesome_python_app/__init__.py,sha256=1I7wHHoj96peEdOGE_jNwR1U2t_8AmnNUZRidc_-CCI,60
|
|
2
|
+
create_awesome_python_app/catalog.py,sha256=MM4lvEGxdPrho9CkGairhT0nYy_eesVgD1wGyJx3gaM,5564
|
|
3
|
+
create_awesome_python_app/cli.py,sha256=kbxeFaroiDe_UhHyM6lt5VhkDzixoCaMmudFLvZAvsI,7012
|
|
4
|
+
create_awesome_python_app-0.1.0.dist-info/METADATA,sha256=iqsjgMLGNgRigLM2pVsiceZwew6S6gHiwL8EP2ixDu0,1005
|
|
5
|
+
create_awesome_python_app-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
6
|
+
create_awesome_python_app-0.1.0.dist-info/entry_points.txt,sha256=rszFbUjY-lvMDZ96zX1lALJwgD1mI3CkUuNyF2qqjYo,81
|
|
7
|
+
create_awesome_python_app-0.1.0.dist-info/RECORD,,
|