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.
@@ -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,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,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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ create-awesome-python-app = create_awesome_python_app.cli:main