create-awesome-python-app 0.2.6__py3-none-any.whl → 0.2.8__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 +1 -1
- create_awesome_python_app/catalog.py +219 -64
- create_awesome_python_app/cli.py +193 -15
- {create_awesome_python_app-0.2.6.dist-info → create_awesome_python_app-0.2.8.dist-info}/METADATA +2 -1
- create_awesome_python_app-0.2.8.dist-info/RECORD +9 -0
- create_awesome_python_app-0.2.6.dist-info/RECORD +0 -9
- {create_awesome_python_app-0.2.6.dist-info → create_awesome_python_app-0.2.8.dist-info}/WHEEL +0 -0
- {create_awesome_python_app-0.2.6.dist-info → create_awesome_python_app-0.2.8.dist-info}/entry_points.txt +0 -0
|
@@ -13,7 +13,6 @@ from typing import Any
|
|
|
13
13
|
|
|
14
14
|
from create_python_app_core.paths import default_cache_dir, resolve_source
|
|
15
15
|
from rich.console import Console
|
|
16
|
-
from rich.table import Table
|
|
17
16
|
|
|
18
17
|
from create_awesome_python_app import __version__
|
|
19
18
|
from create_awesome_python_app.prompt_style import (
|
|
@@ -159,6 +158,17 @@ def validate_extension_compatibility(
|
|
|
159
158
|
raise IncompatibleExtensionsError(pairs)
|
|
160
159
|
|
|
161
160
|
|
|
161
|
+
@dataclass(frozen=True)
|
|
162
|
+
class CategoryInfo:
|
|
163
|
+
"""Catalog category with optional rich metadata (CNA parity)."""
|
|
164
|
+
|
|
165
|
+
slug: str
|
|
166
|
+
name: str
|
|
167
|
+
description: str = ""
|
|
168
|
+
details: str = ""
|
|
169
|
+
labels: tuple[str, ...] = ()
|
|
170
|
+
|
|
171
|
+
|
|
162
172
|
def short_category_label(category_name: str) -> str:
|
|
163
173
|
"""Derive a compact badge label from a catalog category name."""
|
|
164
174
|
stop_words = {"Applications", "Application", "Boilerplate"}
|
|
@@ -168,11 +178,48 @@ def short_category_label(category_name: str) -> str:
|
|
|
168
178
|
return " ".join(words[:2]) or category_name
|
|
169
179
|
|
|
170
180
|
|
|
181
|
+
def category_index(data: dict[str, Any]) -> dict[str, CategoryInfo]:
|
|
182
|
+
"""Index categories by slug, including description/details/labels."""
|
|
183
|
+
out: dict[str, CategoryInfo] = {}
|
|
184
|
+
for raw in data.get("categories", []):
|
|
185
|
+
if not isinstance(raw, dict):
|
|
186
|
+
continue
|
|
187
|
+
slug = str(raw.get("slug", "")).strip()
|
|
188
|
+
if not slug:
|
|
189
|
+
continue
|
|
190
|
+
labels_raw = raw.get("labels", [])
|
|
191
|
+
labels = (
|
|
192
|
+
tuple(str(label) for label in labels_raw)
|
|
193
|
+
if isinstance(labels_raw, list)
|
|
194
|
+
else ()
|
|
195
|
+
)
|
|
196
|
+
out[slug] = CategoryInfo(
|
|
197
|
+
slug=slug,
|
|
198
|
+
name=str(raw.get("name", slug)),
|
|
199
|
+
description=str(raw.get("description", "")).strip(),
|
|
200
|
+
details=str(raw.get("details", "")).strip(),
|
|
201
|
+
labels=labels,
|
|
202
|
+
)
|
|
203
|
+
return out
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def get_category_data(data: dict[str, Any], slug: str) -> CategoryInfo | None:
|
|
207
|
+
return category_index(data).get(slug)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def format_category_choice_title(info: CategoryInfo, extension_count: int) -> str:
|
|
211
|
+
"""Human title for the interactive extension-category picker."""
|
|
212
|
+
noun = "extension" if extension_count == 1 else "extensions"
|
|
213
|
+
title = f"{info.name} ({extension_count} {noun})"
|
|
214
|
+
if info.description:
|
|
215
|
+
title = f"{title} — {info.description}"
|
|
216
|
+
if info.labels:
|
|
217
|
+
title = f"{title} · {', '.join(info.labels[:3])}"
|
|
218
|
+
return title
|
|
219
|
+
|
|
220
|
+
|
|
171
221
|
def _category_map(data: dict[str, Any]) -> dict[str, str]:
|
|
172
|
-
return {
|
|
173
|
-
str(category.get("slug", "")): str(category.get("name", ""))
|
|
174
|
-
for category in data.get("categories", [])
|
|
175
|
-
}
|
|
222
|
+
return {slug: info.name for slug, info in category_index(data).items()}
|
|
176
223
|
|
|
177
224
|
|
|
178
225
|
def _search_text(template: dict[str, Any], category_name: str) -> str:
|
|
@@ -344,9 +391,9 @@ CACHE_TTL_SECONDS = 3600
|
|
|
344
391
|
FETCH_TIMEOUT_SECONDS = 10
|
|
345
392
|
USER_AGENT = f"create-awesome-python-app/{__version__} (https://github.com/Create-Python-App/create-python-app)"
|
|
346
393
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
394
|
+
_AUTO_FIXTURE_DIR = Path(__file__).resolve().parents[4]
|
|
395
|
+
_SENTINEL = object()
|
|
396
|
+
_fixture_root_override: Path | None | object = _SENTINEL
|
|
350
397
|
|
|
351
398
|
_memory_cache: dict[str, Any] | None = None
|
|
352
399
|
_memory_ts: float = 0.0
|
|
@@ -360,13 +407,61 @@ def catalog_cache_path() -> Path:
|
|
|
360
407
|
return default_cache_dir() / "catalog" / "templates.json"
|
|
361
408
|
|
|
362
409
|
|
|
410
|
+
def resolve_fixture_root() -> Path | None:
|
|
411
|
+
"""Resolve the repo root that contains ``fixtures/catalog/templates.json``.
|
|
412
|
+
|
|
413
|
+
Priority: ``CPA_FIXTURE_DIR`` → walk-up from package → ``cwd``.
|
|
414
|
+
"""
|
|
415
|
+
if _fixture_root_override is not _SENTINEL:
|
|
416
|
+
return _fixture_root_override # type: ignore[return-value]
|
|
417
|
+
|
|
418
|
+
env = os.environ.get("CPA_FIXTURE_DIR", "").strip()
|
|
419
|
+
if env:
|
|
420
|
+
return Path(env).expanduser().resolve()
|
|
421
|
+
|
|
422
|
+
def _has_fixture_catalog(root: Path) -> bool:
|
|
423
|
+
return (root / "fixtures" / "catalog" / "templates.json").is_file()
|
|
424
|
+
|
|
425
|
+
if _has_fixture_catalog(_AUTO_FIXTURE_DIR):
|
|
426
|
+
return _AUTO_FIXTURE_DIR
|
|
427
|
+
|
|
428
|
+
# Editable / site-packages layouts vary; walk up from this file.
|
|
429
|
+
for parent in Path(__file__).resolve().parents:
|
|
430
|
+
if _has_fixture_catalog(parent):
|
|
431
|
+
return parent
|
|
432
|
+
|
|
433
|
+
cwd = Path.cwd()
|
|
434
|
+
if _has_fixture_catalog(cwd):
|
|
435
|
+
return cwd
|
|
436
|
+
return None
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def set_fixture_root_for_tests(root: Path | None) -> None:
|
|
440
|
+
"""Override fixture root (test helper)."""
|
|
441
|
+
global _fixture_root_override
|
|
442
|
+
_fixture_root_override = root
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def reset_fixture_root_for_tests() -> None:
|
|
446
|
+
global _fixture_root_override
|
|
447
|
+
_fixture_root_override = _SENTINEL
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def fixture_catalog_path() -> Path | None:
|
|
451
|
+
root = resolve_fixture_root()
|
|
452
|
+
if root is None:
|
|
453
|
+
return None
|
|
454
|
+
return root / "fixtures" / "catalog" / "templates.json"
|
|
455
|
+
|
|
456
|
+
|
|
363
457
|
def _read_json_file(path: Path) -> dict[str, Any]:
|
|
364
458
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
365
459
|
|
|
366
460
|
|
|
367
461
|
def _read_fixture() -> dict[str, Any]:
|
|
368
|
-
|
|
369
|
-
|
|
462
|
+
path = fixture_catalog_path()
|
|
463
|
+
if path is not None and path.is_file():
|
|
464
|
+
return _read_json_file(path)
|
|
370
465
|
return {"templates": [], "extensions": [], "categories": []}
|
|
371
466
|
|
|
372
467
|
|
|
@@ -417,6 +512,19 @@ def get_catalog_data(*, force_refresh: bool = False) -> dict[str, Any]:
|
|
|
417
512
|
"""Load templates.json from remote URL, disk cache, or local fixture."""
|
|
418
513
|
global _memory_cache, _memory_ts
|
|
419
514
|
|
|
515
|
+
if os.environ.get("CPA_CATALOG_FIXTURE") == "1":
|
|
516
|
+
path = fixture_catalog_path()
|
|
517
|
+
if path is None or not path.is_file():
|
|
518
|
+
raise RuntimeError(
|
|
519
|
+
"Fixture mode is enabled (CPA_CATALOG_FIXTURE=1) but the fixture "
|
|
520
|
+
"root could not be resolved. Set CPA_FIXTURE_DIR to the repo root "
|
|
521
|
+
"containing fixtures/catalog/templates.json."
|
|
522
|
+
)
|
|
523
|
+
data = _read_fixture()
|
|
524
|
+
_memory_cache = data
|
|
525
|
+
_memory_ts = time.time()
|
|
526
|
+
return data
|
|
527
|
+
|
|
420
528
|
if (
|
|
421
529
|
not force_refresh
|
|
422
530
|
and _memory_cache is not None
|
|
@@ -425,38 +533,33 @@ def get_catalog_data(*, force_refresh: bool = False) -> dict[str, Any]:
|
|
|
425
533
|
):
|
|
426
534
|
return _memory_cache
|
|
427
535
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
536
|
+
url = catalog_url()
|
|
537
|
+
try:
|
|
538
|
+
data = _fetch_remote(url)
|
|
539
|
+
_write_disk_cache(data)
|
|
540
|
+
except (
|
|
541
|
+
urllib.error.URLError,
|
|
542
|
+
TimeoutError,
|
|
543
|
+
OSError,
|
|
544
|
+
json.JSONDecodeError,
|
|
545
|
+
) as err:
|
|
546
|
+
disk = _read_disk_cache()
|
|
547
|
+
if disk is not None:
|
|
548
|
+
console.print(
|
|
549
|
+
"[yellow][cpa] Could not refresh catalog "
|
|
550
|
+
f"({err}); using disk cache.[/yellow]"
|
|
551
|
+
)
|
|
552
|
+
data = disk
|
|
553
|
+
else:
|
|
554
|
+
fixture = _read_fixture()
|
|
555
|
+
if fixture.get("templates"):
|
|
443
556
|
console.print(
|
|
444
557
|
"[yellow][cpa] Could not refresh catalog "
|
|
445
|
-
f"({err}); using
|
|
558
|
+
f"({err}); using fixture.[/yellow]"
|
|
446
559
|
)
|
|
447
|
-
data =
|
|
560
|
+
data = fixture
|
|
448
561
|
else:
|
|
449
|
-
|
|
450
|
-
if fixture.get("templates"):
|
|
451
|
-
console.print(
|
|
452
|
-
"[yellow][cpa] Could not refresh catalog "
|
|
453
|
-
f"({err}); using fixture.[/yellow]"
|
|
454
|
-
)
|
|
455
|
-
data = fixture
|
|
456
|
-
else:
|
|
457
|
-
raise RuntimeError(
|
|
458
|
-
f"Failed to load template catalog: {err}"
|
|
459
|
-
) from err
|
|
562
|
+
raise RuntimeError(f"Failed to load template catalog: {err}") from err
|
|
460
563
|
|
|
461
564
|
_memory_cache = data
|
|
462
565
|
_memory_ts = time.time()
|
|
@@ -471,44 +574,96 @@ def reset_catalog_cache_for_tests() -> None:
|
|
|
471
574
|
|
|
472
575
|
def list_templates() -> None:
|
|
473
576
|
data = get_catalog_data()
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
)
|
|
484
|
-
|
|
577
|
+
categories = category_index(data)
|
|
578
|
+
templates = [t for t in data.get("templates", []) if isinstance(t, dict)]
|
|
579
|
+
|
|
580
|
+
console.print("[bold blue]\nAvailable Templates:[/bold blue]")
|
|
581
|
+
# Preserve catalog category order, then any unknown slugs.
|
|
582
|
+
ordered_slugs = list(categories)
|
|
583
|
+
seen: set[str] = set()
|
|
584
|
+
for slug in ordered_slugs:
|
|
585
|
+
seen.add(slug)
|
|
586
|
+
group = [t for t in templates if str(t.get("category", "")) == slug]
|
|
587
|
+
if not group:
|
|
588
|
+
continue
|
|
589
|
+
info = categories[slug]
|
|
590
|
+
console.print(f"[bold green]\n{info.name}:[/bold green]")
|
|
591
|
+
if info.description:
|
|
592
|
+
console.print(f" {info.description}")
|
|
593
|
+
if info.details:
|
|
594
|
+
console.print(f" [dim]{info.details}[/dim]")
|
|
595
|
+
if info.labels:
|
|
596
|
+
console.print(f" [dim]Keywords: {', '.join(info.labels)}[/dim]")
|
|
597
|
+
for template in group:
|
|
598
|
+
name = str(template.get("name", template.get("slug", "")))
|
|
599
|
+
tslug = str(template.get("slug", ""))
|
|
600
|
+
console.print(f" [yellow]{name}[/yellow] ([cyan]{tslug}[/cyan])")
|
|
601
|
+
desc = str(template.get("description", "")).strip()
|
|
602
|
+
if desc:
|
|
603
|
+
console.print(f" {desc}")
|
|
604
|
+
labels = template.get("labels", [])
|
|
605
|
+
if isinstance(labels, list) and labels:
|
|
606
|
+
console.print(f" Keywords: {', '.join(str(x) for x in labels)}")
|
|
607
|
+
|
|
608
|
+
orphans = [t for t in templates if str(t.get("category", "")) not in seen]
|
|
609
|
+
if orphans:
|
|
610
|
+
console.print("[bold green]\nOther:[/bold green]")
|
|
611
|
+
for template in orphans:
|
|
612
|
+
name = str(template.get("name", template.get("slug", "")))
|
|
613
|
+
tslug = str(template.get("slug", ""))
|
|
614
|
+
console.print(f" [yellow]{name}[/yellow] ([cyan]{tslug}[/cyan])")
|
|
485
615
|
|
|
486
616
|
|
|
487
617
|
def list_addons(template_slug: str | None = None) -> None:
|
|
488
618
|
data = get_catalog_data()
|
|
619
|
+
categories = category_index(data)
|
|
489
620
|
template_type: str | None = None
|
|
490
621
|
if template_slug:
|
|
491
622
|
for t in data.get("templates", []):
|
|
492
|
-
if t.get("slug") == template_slug:
|
|
623
|
+
if isinstance(t, dict) and t.get("slug") == template_slug:
|
|
493
624
|
template_type = str(t.get("type", ""))
|
|
494
625
|
break
|
|
495
626
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
627
|
+
console.print("[bold blue]\nAvailable Addons:[/bold blue]")
|
|
628
|
+
if template_slug:
|
|
629
|
+
console.print(
|
|
630
|
+
f"[bold green]\nCompatible with template: {template_slug}[/bold green]"
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
extensions = [
|
|
634
|
+
ext
|
|
635
|
+
for ext in data.get("extensions", data.get("addons", []))
|
|
636
|
+
if isinstance(ext, dict)
|
|
637
|
+
]
|
|
638
|
+
grouped: dict[str, list[dict[str, Any]]] = {}
|
|
639
|
+
for ext in extensions:
|
|
501
640
|
ext_types = ext.get("type", [])
|
|
502
641
|
if isinstance(ext_types, str):
|
|
503
642
|
ext_types = [ext_types]
|
|
504
643
|
if template_type and template_type not in ext_types:
|
|
505
644
|
continue
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
)
|
|
514
|
-
|
|
645
|
+
slug = str(ext.get("category", "custom"))
|
|
646
|
+
grouped.setdefault(slug, []).append(ext)
|
|
647
|
+
|
|
648
|
+
for slug in list(categories) + [s for s in grouped if s not in categories]:
|
|
649
|
+
group = grouped.get(slug)
|
|
650
|
+
if not group:
|
|
651
|
+
continue
|
|
652
|
+
info = categories.get(slug) or CategoryInfo(slug=slug, name=slug)
|
|
653
|
+
console.print(f"[bold green]\n{info.name}:[/bold green]")
|
|
654
|
+
if info.description:
|
|
655
|
+
console.print(f" {info.description}")
|
|
656
|
+
if info.details:
|
|
657
|
+
console.print(f" [dim]{info.details}[/dim]")
|
|
658
|
+
if info.labels:
|
|
659
|
+
console.print(f" [dim]Keywords: {', '.join(info.labels)}[/dim]")
|
|
660
|
+
for ext in group:
|
|
661
|
+
name = str(ext.get("name", ext.get("slug", "")))
|
|
662
|
+
eslug = str(ext.get("slug", ""))
|
|
663
|
+
console.print(f" [yellow]{name}[/yellow] ([cyan]{eslug}[/cyan])")
|
|
664
|
+
desc = str(ext.get("description", "")).strip()
|
|
665
|
+
if desc:
|
|
666
|
+
console.print(f" {desc}")
|
|
667
|
+
labels = ext.get("labels", [])
|
|
668
|
+
if isinstance(labels, list) and labels:
|
|
669
|
+
console.print(f" Keywords: {', '.join(str(x) for x in labels)}")
|
create_awesome_python_app/cli.py
CHANGED
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import os
|
|
7
|
+
import sys
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
from typing import Any
|
|
9
10
|
|
|
@@ -25,17 +26,103 @@ from rich.console import Console
|
|
|
25
26
|
|
|
26
27
|
from create_awesome_python_app import __version__
|
|
27
28
|
|
|
29
|
+
# Sentinel for bare ``--fixture`` (optional DIR rewritten in argv preprocess).
|
|
30
|
+
_FIXTURE_AUTO = "__CPA_FIXTURE_AUTO__"
|
|
31
|
+
|
|
28
32
|
app = typer.Typer(
|
|
29
33
|
name="create-awesome-python-app",
|
|
30
34
|
help="Composable scaffolding CLI for production-ready Python apps.",
|
|
31
35
|
no_args_is_help=False,
|
|
32
36
|
add_completion=True,
|
|
37
|
+
# Keep cache out of this Typer app (routed in main()). A nested command
|
|
38
|
+
# group turns the CLI into Click Group form `[ARGS] COMMAND`, so
|
|
39
|
+
# `cpa my-api --template …` fails with "No such command '--template'".
|
|
40
|
+
epilog="Cache: create-awesome-python-app cache [list|dir|clean|verify|…]",
|
|
33
41
|
)
|
|
34
42
|
cache_app = typer.Typer(help="Inspect and manage the local template cache")
|
|
35
|
-
app.add_typer(cache_app, name="cache")
|
|
36
43
|
console = Console(stderr=True)
|
|
37
44
|
|
|
38
45
|
|
|
46
|
+
def _preprocess_fixture_argv(argv: list[str] | None = None) -> list[str]:
|
|
47
|
+
"""Rewrite bare ``--fixture`` to ``--fixture=__CPA_FIXTURE_AUTO__``.
|
|
48
|
+
|
|
49
|
+
Typer/Click requires an option argument; Commander allows ``--fixture [dir]``.
|
|
50
|
+
This keeps CNA-compatible UX: ``--fixture`` alone enables auto-detect mode.
|
|
51
|
+
"""
|
|
52
|
+
raw = list(sys.argv if argv is None else argv)
|
|
53
|
+
if not raw:
|
|
54
|
+
return raw
|
|
55
|
+
out = [raw[0]]
|
|
56
|
+
i = 1
|
|
57
|
+
while i < len(raw):
|
|
58
|
+
arg = raw[i]
|
|
59
|
+
if arg == "--fixture":
|
|
60
|
+
if i + 1 < len(raw) and not raw[i + 1].startswith("-"):
|
|
61
|
+
out.extend(["--fixture", raw[i + 1]])
|
|
62
|
+
i += 2
|
|
63
|
+
else:
|
|
64
|
+
out.append(f"--fixture={_FIXTURE_AUTO}")
|
|
65
|
+
i += 1
|
|
66
|
+
continue
|
|
67
|
+
out.append(arg)
|
|
68
|
+
i += 1
|
|
69
|
+
if argv is None:
|
|
70
|
+
sys.argv = out
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _expand_variadic_option(argv: list[str], option: str) -> list[str]:
|
|
75
|
+
"""Expand ``--addons a b`` into ``--addons a --addons b`` (CNA Commander parity).
|
|
76
|
+
|
|
77
|
+
Typer's ``list[str]`` Option only accepts one value per flag. Commander uses
|
|
78
|
+
``--addons [extensions...]``, so users naturally write space-separated lists.
|
|
79
|
+
"""
|
|
80
|
+
out: list[str] = []
|
|
81
|
+
i = 0
|
|
82
|
+
prefix = option + "="
|
|
83
|
+
while i < len(argv):
|
|
84
|
+
arg = argv[i]
|
|
85
|
+
if arg == option:
|
|
86
|
+
i += 1
|
|
87
|
+
values: list[str] = []
|
|
88
|
+
while i < len(argv) and not argv[i].startswith("-"):
|
|
89
|
+
values.append(argv[i])
|
|
90
|
+
i += 1
|
|
91
|
+
if not values:
|
|
92
|
+
out.append(option)
|
|
93
|
+
else:
|
|
94
|
+
for value in values:
|
|
95
|
+
out.extend([option, value])
|
|
96
|
+
continue
|
|
97
|
+
if arg.startswith(prefix):
|
|
98
|
+
value = arg[len(prefix) :]
|
|
99
|
+
out.extend([option, value] if value else [option])
|
|
100
|
+
i += 1
|
|
101
|
+
continue
|
|
102
|
+
out.append(arg)
|
|
103
|
+
i += 1
|
|
104
|
+
return out
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _preprocess_cli_argv(argv: list[str] | None = None) -> list[str]:
|
|
108
|
+
"""Apply argv rewrites needed before Typer parses the CLI."""
|
|
109
|
+
out = _preprocess_fixture_argv(argv)
|
|
110
|
+
out = _expand_variadic_option(out, "--addons")
|
|
111
|
+
out = _expand_variadic_option(out, "--extend")
|
|
112
|
+
if argv is None:
|
|
113
|
+
sys.argv = out
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def apply_fixture_mode(fixture: str | None) -> None:
|
|
118
|
+
"""Translate ``--fixture`` into ``CPA_CATALOG_FIXTURE`` / ``CPA_FIXTURE_DIR``."""
|
|
119
|
+
if fixture is None and os.environ.get("CPA_CATALOG_FIXTURE") != "1":
|
|
120
|
+
return
|
|
121
|
+
os.environ["CPA_CATALOG_FIXTURE"] = "1"
|
|
122
|
+
if fixture is not None and fixture != _FIXTURE_AUTO and fixture != "":
|
|
123
|
+
os.environ["CPA_FIXTURE_DIR"] = fixture
|
|
124
|
+
|
|
125
|
+
|
|
39
126
|
def _in_ci() -> bool:
|
|
40
127
|
return os.environ.get("CI", "").lower() in {"1", "true", "yes"}
|
|
41
128
|
|
|
@@ -159,9 +246,8 @@ def main() -> None:
|
|
|
159
246
|
`create-awesome-python-app cache dir` works (Typer would otherwise
|
|
160
247
|
treat `cache` as project_directory).
|
|
161
248
|
"""
|
|
162
|
-
import sys
|
|
163
|
-
|
|
164
249
|
check_python_version(">=3.12", "create-awesome-python-app")
|
|
250
|
+
_preprocess_cli_argv()
|
|
165
251
|
if len(sys.argv) > 1 and sys.argv[1] == "cache":
|
|
166
252
|
sys.argv = [sys.argv[0], *sys.argv[2:]]
|
|
167
253
|
cache_app(prog_name="create-awesome-python-app cache")
|
|
@@ -169,9 +255,8 @@ def main() -> None:
|
|
|
169
255
|
app()
|
|
170
256
|
|
|
171
257
|
|
|
172
|
-
@app.
|
|
258
|
+
@app.command(name="create-awesome-python-app")
|
|
173
259
|
def scaffold(
|
|
174
|
-
ctx: typer.Context,
|
|
175
260
|
project_directory: str | None = typer.Argument("my-project"),
|
|
176
261
|
version: bool = typer.Option(False, "--version"),
|
|
177
262
|
info: bool = typer.Option(False, "--info", "-i"),
|
|
@@ -192,14 +277,25 @@ def scaffold(
|
|
|
192
277
|
refresh: str | None = typer.Option(None, "--refresh"),
|
|
193
278
|
strict_version: bool = typer.Option(False, "--strict-version"),
|
|
194
279
|
keep_on_failure: bool = typer.Option(False, "--keep-on-failure"),
|
|
280
|
+
fixture: str | None = typer.Option(
|
|
281
|
+
None,
|
|
282
|
+
"--fixture",
|
|
283
|
+
help=(
|
|
284
|
+
"Load the template catalog from the local fixtures/ directory "
|
|
285
|
+
"instead of the network (optional DIR = repo root; also "
|
|
286
|
+
"CPA_FIXTURE_DIR / CPA_CATALOG_FIXTURE)"
|
|
287
|
+
),
|
|
288
|
+
),
|
|
195
289
|
) -> None:
|
|
196
290
|
if version:
|
|
197
291
|
console.print(__version__)
|
|
198
292
|
raise typer.Exit(0)
|
|
199
293
|
if info:
|
|
200
294
|
print_env_info()
|
|
201
|
-
|
|
202
|
-
|
|
295
|
+
|
|
296
|
+
# Translate --fixture into env vars before catalog loads
|
|
297
|
+
# (--list-templates / interactive / scaffold).
|
|
298
|
+
apply_fixture_mode(fixture)
|
|
203
299
|
|
|
204
300
|
if list_templates or list_addons:
|
|
205
301
|
from create_awesome_python_app.catalog import list_addons as la
|
|
@@ -315,12 +411,22 @@ def scaffold(
|
|
|
315
411
|
extension_choices = build_extension_choices(interactive_catalog, template)
|
|
316
412
|
grouped_extensions = group_extension_choices(extension_choices)
|
|
317
413
|
if grouped_extensions:
|
|
414
|
+
from create_awesome_python_app.catalog import (
|
|
415
|
+
CategoryInfo,
|
|
416
|
+
category_index,
|
|
417
|
+
format_category_choice_title,
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
categories = category_index(interactive_catalog)
|
|
318
421
|
category_choices = [
|
|
319
422
|
Choice(
|
|
320
|
-
title=(
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
423
|
+
title=format_category_choice_title(
|
|
424
|
+
categories.get(category_slug)
|
|
425
|
+
or CategoryInfo(
|
|
426
|
+
slug=category_slug,
|
|
427
|
+
name=choices[0].category_name,
|
|
428
|
+
),
|
|
429
|
+
len(choices),
|
|
324
430
|
),
|
|
325
431
|
value=category_slug,
|
|
326
432
|
)
|
|
@@ -444,8 +550,29 @@ def cache_dir_cmd() -> None:
|
|
|
444
550
|
console.print(str(default_cache_dir()))
|
|
445
551
|
|
|
446
552
|
|
|
553
|
+
def _cache_json_print(payload: Any) -> None:
|
|
554
|
+
"""Print machine-readable JSON to stdout (not the stderr console)."""
|
|
555
|
+
import json
|
|
556
|
+
from dataclasses import asdict, is_dataclass
|
|
557
|
+
|
|
558
|
+
def _to_jsonable(value: Any) -> Any:
|
|
559
|
+
if isinstance(value, Path):
|
|
560
|
+
return str(value)
|
|
561
|
+
if is_dataclass(value) and not isinstance(value, type):
|
|
562
|
+
return {k: _to_jsonable(v) for k, v in asdict(value).items()}
|
|
563
|
+
if isinstance(value, list):
|
|
564
|
+
return [_to_jsonable(item) for item in value]
|
|
565
|
+
if isinstance(value, dict):
|
|
566
|
+
return {k: _to_jsonable(v) for k, v in value.items()}
|
|
567
|
+
return value
|
|
568
|
+
|
|
569
|
+
typer.echo(json.dumps(_to_jsonable(payload), indent=2))
|
|
570
|
+
|
|
571
|
+
|
|
447
572
|
@cache_app.command("list")
|
|
448
|
-
def cache_list_cmd(
|
|
573
|
+
def cache_list_cmd(
|
|
574
|
+
json_out: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
575
|
+
) -> None:
|
|
449
576
|
from create_awesome_python_app.cache import (
|
|
450
577
|
format_age,
|
|
451
578
|
format_bytes,
|
|
@@ -454,6 +581,9 @@ def cache_list_cmd() -> None:
|
|
|
454
581
|
)
|
|
455
582
|
|
|
456
583
|
entries = list_cache_entries()
|
|
584
|
+
if json_out:
|
|
585
|
+
_cache_json_print(entries)
|
|
586
|
+
return
|
|
457
587
|
if not entries:
|
|
458
588
|
console.print("[dim]No cached templates or extensions.[/dim]")
|
|
459
589
|
console.print(
|
|
@@ -488,10 +618,38 @@ def cache_list_cmd() -> None:
|
|
|
488
618
|
def cache_clean_cmd(
|
|
489
619
|
id: str | None = typer.Argument(None),
|
|
490
620
|
catalog: bool = typer.Option(False, "--catalog"),
|
|
621
|
+
json_out: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
622
|
+
force: bool = typer.Option(
|
|
623
|
+
False, "--force", "-f", help="Skip interactive confirmation"
|
|
624
|
+
),
|
|
491
625
|
) -> None:
|
|
492
626
|
from create_awesome_python_app.cache import clean_cache
|
|
493
627
|
|
|
628
|
+
# Targeted cleans (id / --catalog) never prompt.
|
|
629
|
+
if not catalog and id is None and not json_out and not force:
|
|
630
|
+
if not sys.stdin.isatty():
|
|
631
|
+
console.print(
|
|
632
|
+
"[yellow]Non-interactive shell — use --json or --force to skip "
|
|
633
|
+
"the prompt, or specify an id to target a specific entry.[/yellow]"
|
|
634
|
+
)
|
|
635
|
+
return
|
|
636
|
+
import questionary
|
|
637
|
+
|
|
638
|
+
from create_awesome_python_app.prompt_style import CPA_PROMPT_STYLE
|
|
639
|
+
|
|
640
|
+
confirmed = questionary.confirm(
|
|
641
|
+
"Remove ALL cached templates and extensions?",
|
|
642
|
+
default=False,
|
|
643
|
+
style=CPA_PROMPT_STYLE,
|
|
644
|
+
).ask()
|
|
645
|
+
if not confirmed:
|
|
646
|
+
console.print("[dim]Clean cancelled.[/dim]")
|
|
647
|
+
return
|
|
648
|
+
|
|
494
649
|
result = clean_cache(id, catalog=catalog)
|
|
650
|
+
if json_out:
|
|
651
|
+
_cache_json_print(result)
|
|
652
|
+
return
|
|
495
653
|
if result.not_found:
|
|
496
654
|
console.print(f"[yellow]No cache entry found for id: {id}[/yellow]")
|
|
497
655
|
return
|
|
@@ -503,10 +661,18 @@ def cache_clean_cmd(
|
|
|
503
661
|
|
|
504
662
|
|
|
505
663
|
@cache_app.command("verify")
|
|
506
|
-
def cache_verify_cmd(
|
|
664
|
+
def cache_verify_cmd(
|
|
665
|
+
id: str | None = typer.Argument(None),
|
|
666
|
+
json_out: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
667
|
+
) -> None:
|
|
507
668
|
from create_awesome_python_app.cache import verify_cache
|
|
508
669
|
|
|
509
670
|
results = verify_cache(id)
|
|
671
|
+
if json_out:
|
|
672
|
+
_cache_json_print(results)
|
|
673
|
+
if any(not bool(entry.fsck_ok) for entry in results):
|
|
674
|
+
raise typer.Exit(1)
|
|
675
|
+
raise typer.Exit(0)
|
|
510
676
|
if not results:
|
|
511
677
|
console.print("[dim]No cached entries.[/dim]")
|
|
512
678
|
raise typer.Exit(0)
|
|
@@ -527,10 +693,15 @@ def cache_verify_cmd(id: str | None = typer.Argument(None)) -> None:
|
|
|
527
693
|
|
|
528
694
|
|
|
529
695
|
@cache_app.command("outdated")
|
|
530
|
-
def cache_outdated_cmd(
|
|
696
|
+
def cache_outdated_cmd(
|
|
697
|
+
json_out: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
698
|
+
) -> None:
|
|
531
699
|
from create_awesome_python_app.cache import check_outdated
|
|
532
700
|
|
|
533
701
|
results = check_outdated()
|
|
702
|
+
if json_out:
|
|
703
|
+
_cache_json_print(results)
|
|
704
|
+
return
|
|
534
705
|
if not results:
|
|
535
706
|
console.print("[dim]No cached entries to check.[/dim]")
|
|
536
707
|
return
|
|
@@ -590,10 +761,17 @@ def cache_update_cmd(id: str | None = typer.Argument(None)) -> None:
|
|
|
590
761
|
|
|
591
762
|
|
|
592
763
|
@cache_app.command("doctor")
|
|
593
|
-
def cache_doctor_cmd(
|
|
764
|
+
def cache_doctor_cmd(
|
|
765
|
+
json_out: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
766
|
+
) -> None:
|
|
594
767
|
from create_awesome_python_app.cache import run_doctor
|
|
595
768
|
|
|
596
769
|
results = run_doctor()
|
|
770
|
+
if json_out:
|
|
771
|
+
_cache_json_print(results)
|
|
772
|
+
if any(not row.ok for row in results):
|
|
773
|
+
raise typer.Exit(1)
|
|
774
|
+
raise typer.Exit(0)
|
|
597
775
|
all_ok = True
|
|
598
776
|
for row in results:
|
|
599
777
|
mark = "[green]✓[/green]" if row.ok else "[red]✗[/red]"
|
{create_awesome_python_app-0.2.6.dist-info → create_awesome_python_app-0.2.8.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: create-awesome-python-app
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.8
|
|
4
4
|
Summary: Composable scaffolding CLI for production-ready Python apps
|
|
5
5
|
Project-URL: Homepage, https://github.com/Create-Python-App/create-python-app
|
|
6
6
|
Project-URL: Repository, https://github.com/Create-Python-App/create-python-app
|
|
@@ -42,6 +42,7 @@ From blank folder to a working FastAPI, Django, Celery, CLI, or uv workspace pro
|
|
|
42
42
|
[![MegaLinter][megalinterbadge]][megalinterurl]
|
|
43
43
|
[![Shellcheck][shellcheckbadge]][shellcheckurl]
|
|
44
44
|
[![Commit Activity][commitactivitybadge]][commitactivityurl]
|
|
45
|
+
[](https://discord.gg/dwFTsR7fK2)
|
|
45
46
|
|
|
46
47
|
**[Official Site](https://create-awesome-python-app.vercel.app)** · [Templates](https://create-awesome-python-app.vercel.app/templates) · [Extensions](https://create-awesome-python-app.vercel.app/extensions) · [Docs](https://create-awesome-python-app.vercel.app/docs) · [GitHub](https://github.com/Create-Python-App/create-python-app) · [PyPI](https://pypi.org/project/create-awesome-python-app/)
|
|
47
48
|
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
create_awesome_python_app/__init__.py,sha256=bpha_GXZNmruh0uP4KvEZzHYZOOzyqluLF2ediJjcdo,60
|
|
2
|
+
create_awesome_python_app/cache.py,sha256=eHKUlunGexZKYJo0HDNoBoOVq0DjLyRYgEe_wUh4Fk0,11812
|
|
3
|
+
create_awesome_python_app/catalog.py,sha256=ZB-ieyEn_TBqVBbCbFkK9h3VJoYtifvvOxs_BM2tDEw,22754
|
|
4
|
+
create_awesome_python_app/cli.py,sha256=bSmivzm4lUOcDtoyNT-t3PL_Xb1O8msAUl_XFczeNkQ,27555
|
|
5
|
+
create_awesome_python_app/prompt_style.py,sha256=pERX1qPQSLZXeJvPVFC7z85dvte73ZV4iuENPQduheE,3463
|
|
6
|
+
create_awesome_python_app-0.2.8.dist-info/METADATA,sha256=YcMQFDgWXeK0jT77VqBV6LOYNBxfTWOhhYRWpZ1A9W0,23271
|
|
7
|
+
create_awesome_python_app-0.2.8.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
8
|
+
create_awesome_python_app-0.2.8.dist-info/entry_points.txt,sha256=rszFbUjY-lvMDZ96zX1lALJwgD1mI3CkUuNyF2qqjYo,81
|
|
9
|
+
create_awesome_python_app-0.2.8.dist-info/RECORD,,
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
create_awesome_python_app/__init__.py,sha256=jCOrfI56gv-0n6QwEJfZ9bLqCHZ00wfNByGXx5mr5xM,60
|
|
2
|
-
create_awesome_python_app/cache.py,sha256=eHKUlunGexZKYJo0HDNoBoOVq0DjLyRYgEe_wUh4Fk0,11812
|
|
3
|
-
create_awesome_python_app/catalog.py,sha256=w5GS_kQ6MM37AilutXbYZ9Hk5JGa_12crzI0LYzKfRg,16821
|
|
4
|
-
create_awesome_python_app/cli.py,sha256=6og82WI-RZxTqHETqrieA8relgppwCqFntOEmOFrIPk,21195
|
|
5
|
-
create_awesome_python_app/prompt_style.py,sha256=pERX1qPQSLZXeJvPVFC7z85dvte73ZV4iuENPQduheE,3463
|
|
6
|
-
create_awesome_python_app-0.2.6.dist-info/METADATA,sha256=aRJu55BsSSuTwcWZguOHt03n5RW6trGGhJ2pIu9vJiY,23132
|
|
7
|
-
create_awesome_python_app-0.2.6.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
8
|
-
create_awesome_python_app-0.2.6.dist-info/entry_points.txt,sha256=rszFbUjY-lvMDZ96zX1lALJwgD1mI3CkUuNyF2qqjYo,81
|
|
9
|
-
create_awesome_python_app-0.2.6.dist-info/RECORD,,
|
{create_awesome_python_app-0.2.6.dist-info → create_awesome_python_app-0.2.8.dist-info}/WHEEL
RENAMED
|
File without changes
|
|
File without changes
|