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.
@@ -1,3 +1,3 @@
1
1
  """Create Awesome Python App CLI."""
2
2
 
3
- __version__ = "0.2.6"
3
+ __version__ = "0.2.8"
@@ -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
- _FIXTURE = (
348
- Path(__file__).resolve().parents[4] / "fixtures" / "catalog" / "templates.json"
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
- if _FIXTURE.is_file():
369
- return _read_json_file(_FIXTURE)
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
- if os.environ.get("CPA_CATALOG_FIXTURE") == "1":
429
- data = _read_fixture()
430
- else:
431
- url = catalog_url()
432
- try:
433
- data = _fetch_remote(url)
434
- _write_disk_cache(data)
435
- except (
436
- urllib.error.URLError,
437
- TimeoutError,
438
- OSError,
439
- json.JSONDecodeError,
440
- ) as err:
441
- disk = _read_disk_cache()
442
- if disk is not None:
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 disk cache.[/yellow]"
558
+ f"({err}); using fixture.[/yellow]"
446
559
  )
447
- data = disk
560
+ data = fixture
448
561
  else:
449
- fixture = _read_fixture()
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
- table = Table(title="Templates")
475
- table.add_column("slug")
476
- table.add_column("category")
477
- table.add_column("type")
478
- for t in data.get("templates", []):
479
- table.add_row(
480
- str(t.get("slug", "")),
481
- str(t.get("category", "")),
482
- str(t.get("type", "")),
483
- )
484
- console.print(table)
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
- table = Table(title="Extensions")
497
- table.add_column("slug")
498
- table.add_column("category")
499
- table.add_column("type")
500
- for ext in data.get("extensions", data.get("addons", [])):
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
- type_label = (
507
- ", ".join(ext_types) if isinstance(ext_types, list) else str(ext_types)
508
- )
509
- table.add_row(
510
- str(ext.get("slug", "")),
511
- str(ext.get("category", "")),
512
- type_label,
513
- )
514
- console.print(table)
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)}")
@@ -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.callback(invoke_without_command=True)
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
- if ctx.invoked_subcommand is not None:
202
- return
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
- f"{choices[0].category_name} "
322
- f"({len(choices)} extension"
323
- f"{'s' if len(choices) != 1 else ''})"
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() -> None:
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(id: str | None = typer.Argument(None)) -> None:
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() -> None:
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() -> None:
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]"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: create-awesome-python-app
3
- Version: 0.2.6
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
+ [![Discord](https://img.shields.io/discord/1527933660764831825?label=Discord&logo=discord&logoColor=white)](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,,