create-awesome-python-app 0.2.6__tar.gz → 0.2.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.7}/PKG-INFO +2 -1
  2. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.7}/README.md +1 -0
  3. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.7}/pyproject.toml +1 -1
  4. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.7}/src/create_awesome_python_app/__init__.py +1 -1
  5. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.7}/src/create_awesome_python_app/catalog.py +219 -64
  6. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.7}/src/create_awesome_python_app/cli.py +150 -15
  7. create_awesome_python_app-0.2.7/tests/conftest.py +39 -0
  8. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.7}/tests/test_cache_cmds.py +69 -0
  9. create_awesome_python_app-0.2.7/tests/test_catalog.py +21 -0
  10. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.7}/tests/test_catalog_fetch.py +40 -1
  11. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.7}/tests/test_catalog_resolve.py +29 -0
  12. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.7}/tests/test_cli.py +122 -10
  13. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.7}/tests/test_cpa_templates_integration.py +21 -0
  14. create_awesome_python_app-0.2.6/tests/test_catalog.py +0 -13
  15. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.7}/.gitignore +0 -0
  16. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.7}/assets/hero.svg +0 -0
  17. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.7}/src/create_awesome_python_app/cache.py +0 -0
  18. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.7}/src/create_awesome_python_app/prompt_style.py +0 -0
  19. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.7}/tests/test_cache_env.py +0 -0
  20. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.7}/tests/test_flags.py +0 -0
  21. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.7}/tests/test_interactive.py +0 -0
  22. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.7}/tests/test_smoke.py +0 -0
@@ -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.7
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
 
@@ -26,6 +26,7 @@ From blank folder to a working FastAPI, Django, Celery, CLI, or uv workspace pro
26
26
  [![MegaLinter][megalinterbadge]][megalinterurl]
27
27
  [![Shellcheck][shellcheckbadge]][shellcheckurl]
28
28
  [![Commit Activity][commitactivitybadge]][commitactivityurl]
29
+ [![Discord](https://img.shields.io/discord/1527933660764831825?label=Discord&logo=discord&logoColor=white)](https://discord.gg/dwFTsR7fK2)
29
30
 
30
31
  **[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/)
31
32
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "create-awesome-python-app"
3
- version = "0.2.6"
3
+ version = "0.2.7"
4
4
  description = "Composable scaffolding CLI for production-ready Python apps"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -1,3 +1,3 @@
1
1
  """Create Awesome Python App CLI."""
2
2
 
3
- __version__ = "0.2.6"
3
+ __version__ = "0.2.7"
@@ -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,60 @@ 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 apply_fixture_mode(fixture: str | None) -> None:
75
+ """Translate ``--fixture`` into ``CPA_CATALOG_FIXTURE`` / ``CPA_FIXTURE_DIR``."""
76
+ if fixture is None and os.environ.get("CPA_CATALOG_FIXTURE") != "1":
77
+ return
78
+ os.environ["CPA_CATALOG_FIXTURE"] = "1"
79
+ if fixture is not None and fixture != _FIXTURE_AUTO and fixture != "":
80
+ os.environ["CPA_FIXTURE_DIR"] = fixture
81
+
82
+
39
83
  def _in_ci() -> bool:
40
84
  return os.environ.get("CI", "").lower() in {"1", "true", "yes"}
41
85
 
@@ -159,9 +203,8 @@ def main() -> None:
159
203
  `create-awesome-python-app cache dir` works (Typer would otherwise
160
204
  treat `cache` as project_directory).
161
205
  """
162
- import sys
163
-
164
206
  check_python_version(">=3.12", "create-awesome-python-app")
207
+ _preprocess_fixture_argv()
165
208
  if len(sys.argv) > 1 and sys.argv[1] == "cache":
166
209
  sys.argv = [sys.argv[0], *sys.argv[2:]]
167
210
  cache_app(prog_name="create-awesome-python-app cache")
@@ -169,9 +212,8 @@ def main() -> None:
169
212
  app()
170
213
 
171
214
 
172
- @app.callback(invoke_without_command=True)
215
+ @app.command(name="create-awesome-python-app")
173
216
  def scaffold(
174
- ctx: typer.Context,
175
217
  project_directory: str | None = typer.Argument("my-project"),
176
218
  version: bool = typer.Option(False, "--version"),
177
219
  info: bool = typer.Option(False, "--info", "-i"),
@@ -192,14 +234,25 @@ def scaffold(
192
234
  refresh: str | None = typer.Option(None, "--refresh"),
193
235
  strict_version: bool = typer.Option(False, "--strict-version"),
194
236
  keep_on_failure: bool = typer.Option(False, "--keep-on-failure"),
237
+ fixture: str | None = typer.Option(
238
+ None,
239
+ "--fixture",
240
+ help=(
241
+ "Load the template catalog from the local fixtures/ directory "
242
+ "instead of the network (optional DIR = repo root; also "
243
+ "CPA_FIXTURE_DIR / CPA_CATALOG_FIXTURE)"
244
+ ),
245
+ ),
195
246
  ) -> None:
196
247
  if version:
197
248
  console.print(__version__)
198
249
  raise typer.Exit(0)
199
250
  if info:
200
251
  print_env_info()
201
- if ctx.invoked_subcommand is not None:
202
- return
252
+
253
+ # Translate --fixture into env vars before catalog loads
254
+ # (--list-templates / interactive / scaffold).
255
+ apply_fixture_mode(fixture)
203
256
 
204
257
  if list_templates or list_addons:
205
258
  from create_awesome_python_app.catalog import list_addons as la
@@ -315,12 +368,22 @@ def scaffold(
315
368
  extension_choices = build_extension_choices(interactive_catalog, template)
316
369
  grouped_extensions = group_extension_choices(extension_choices)
317
370
  if grouped_extensions:
371
+ from create_awesome_python_app.catalog import (
372
+ CategoryInfo,
373
+ category_index,
374
+ format_category_choice_title,
375
+ )
376
+
377
+ categories = category_index(interactive_catalog)
318
378
  category_choices = [
319
379
  Choice(
320
- title=(
321
- f"{choices[0].category_name} "
322
- f"({len(choices)} extension"
323
- f"{'s' if len(choices) != 1 else ''})"
380
+ title=format_category_choice_title(
381
+ categories.get(category_slug)
382
+ or CategoryInfo(
383
+ slug=category_slug,
384
+ name=choices[0].category_name,
385
+ ),
386
+ len(choices),
324
387
  ),
325
388
  value=category_slug,
326
389
  )
@@ -444,8 +507,29 @@ def cache_dir_cmd() -> None:
444
507
  console.print(str(default_cache_dir()))
445
508
 
446
509
 
510
+ def _cache_json_print(payload: Any) -> None:
511
+ """Print machine-readable JSON to stdout (not the stderr console)."""
512
+ import json
513
+ from dataclasses import asdict, is_dataclass
514
+
515
+ def _to_jsonable(value: Any) -> Any:
516
+ if isinstance(value, Path):
517
+ return str(value)
518
+ if is_dataclass(value) and not isinstance(value, type):
519
+ return {k: _to_jsonable(v) for k, v in asdict(value).items()}
520
+ if isinstance(value, list):
521
+ return [_to_jsonable(item) for item in value]
522
+ if isinstance(value, dict):
523
+ return {k: _to_jsonable(v) for k, v in value.items()}
524
+ return value
525
+
526
+ typer.echo(json.dumps(_to_jsonable(payload), indent=2))
527
+
528
+
447
529
  @cache_app.command("list")
448
- def cache_list_cmd() -> None:
530
+ def cache_list_cmd(
531
+ json_out: bool = typer.Option(False, "--json", help="Output as JSON"),
532
+ ) -> None:
449
533
  from create_awesome_python_app.cache import (
450
534
  format_age,
451
535
  format_bytes,
@@ -454,6 +538,9 @@ def cache_list_cmd() -> None:
454
538
  )
455
539
 
456
540
  entries = list_cache_entries()
541
+ if json_out:
542
+ _cache_json_print(entries)
543
+ return
457
544
  if not entries:
458
545
  console.print("[dim]No cached templates or extensions.[/dim]")
459
546
  console.print(
@@ -488,10 +575,38 @@ def cache_list_cmd() -> None:
488
575
  def cache_clean_cmd(
489
576
  id: str | None = typer.Argument(None),
490
577
  catalog: bool = typer.Option(False, "--catalog"),
578
+ json_out: bool = typer.Option(False, "--json", help="Output as JSON"),
579
+ force: bool = typer.Option(
580
+ False, "--force", "-f", help="Skip interactive confirmation"
581
+ ),
491
582
  ) -> None:
492
583
  from create_awesome_python_app.cache import clean_cache
493
584
 
585
+ # Targeted cleans (id / --catalog) never prompt.
586
+ if not catalog and id is None and not json_out and not force:
587
+ if not sys.stdin.isatty():
588
+ console.print(
589
+ "[yellow]Non-interactive shell — use --json or --force to skip "
590
+ "the prompt, or specify an id to target a specific entry.[/yellow]"
591
+ )
592
+ return
593
+ import questionary
594
+
595
+ from create_awesome_python_app.prompt_style import CPA_PROMPT_STYLE
596
+
597
+ confirmed = questionary.confirm(
598
+ "Remove ALL cached templates and extensions?",
599
+ default=False,
600
+ style=CPA_PROMPT_STYLE,
601
+ ).ask()
602
+ if not confirmed:
603
+ console.print("[dim]Clean cancelled.[/dim]")
604
+ return
605
+
494
606
  result = clean_cache(id, catalog=catalog)
607
+ if json_out:
608
+ _cache_json_print(result)
609
+ return
495
610
  if result.not_found:
496
611
  console.print(f"[yellow]No cache entry found for id: {id}[/yellow]")
497
612
  return
@@ -503,10 +618,18 @@ def cache_clean_cmd(
503
618
 
504
619
 
505
620
  @cache_app.command("verify")
506
- def cache_verify_cmd(id: str | None = typer.Argument(None)) -> None:
621
+ def cache_verify_cmd(
622
+ id: str | None = typer.Argument(None),
623
+ json_out: bool = typer.Option(False, "--json", help="Output as JSON"),
624
+ ) -> None:
507
625
  from create_awesome_python_app.cache import verify_cache
508
626
 
509
627
  results = verify_cache(id)
628
+ if json_out:
629
+ _cache_json_print(results)
630
+ if any(not bool(entry.fsck_ok) for entry in results):
631
+ raise typer.Exit(1)
632
+ raise typer.Exit(0)
510
633
  if not results:
511
634
  console.print("[dim]No cached entries.[/dim]")
512
635
  raise typer.Exit(0)
@@ -527,10 +650,15 @@ def cache_verify_cmd(id: str | None = typer.Argument(None)) -> None:
527
650
 
528
651
 
529
652
  @cache_app.command("outdated")
530
- def cache_outdated_cmd() -> None:
653
+ def cache_outdated_cmd(
654
+ json_out: bool = typer.Option(False, "--json", help="Output as JSON"),
655
+ ) -> None:
531
656
  from create_awesome_python_app.cache import check_outdated
532
657
 
533
658
  results = check_outdated()
659
+ if json_out:
660
+ _cache_json_print(results)
661
+ return
534
662
  if not results:
535
663
  console.print("[dim]No cached entries to check.[/dim]")
536
664
  return
@@ -590,10 +718,17 @@ def cache_update_cmd(id: str | None = typer.Argument(None)) -> None:
590
718
 
591
719
 
592
720
  @cache_app.command("doctor")
593
- def cache_doctor_cmd() -> None:
721
+ def cache_doctor_cmd(
722
+ json_out: bool = typer.Option(False, "--json", help="Output as JSON"),
723
+ ) -> None:
594
724
  from create_awesome_python_app.cache import run_doctor
595
725
 
596
726
  results = run_doctor()
727
+ if json_out:
728
+ _cache_json_print(results)
729
+ if any(not row.ok for row in results):
730
+ raise typer.Exit(1)
731
+ raise typer.Exit(0)
597
732
  all_ok = True
598
733
  for row in results:
599
734
  mark = "[green]✓[/green]" if row.ok else "[red]✗[/red]"
@@ -0,0 +1,39 @@
1
+ """Shared test fixtures for create-awesome-python-app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ import pytest
8
+
9
+ _CPA_ENV_VARS = (
10
+ "CPA_REFRESH",
11
+ "CPA_NO_CATALOG_CACHE",
12
+ "CPA_CACHE_DIR",
13
+ "CPA_CATALOG_FIXTURE",
14
+ "CPA_FIXTURE_DIR",
15
+ )
16
+
17
+
18
+ @pytest.fixture(autouse=True)
19
+ def _clean_cpa_process_env():
20
+ """Clear CPA env vars that CLI helpers set via ``os.environ`` (not monkeypatch).
21
+
22
+ ``apply_fixture_mode`` mutates ``os.environ`` directly. Pairing that with
23
+ ``monkeypatch.delenv`` after the test can restore the leaked value when
24
+ monkeypatch undoes its stack — so cleanup must use ``os.environ.pop``.
25
+ """
26
+ from create_awesome_python_app.catalog import (
27
+ reset_catalog_cache_for_tests,
28
+ reset_fixture_root_for_tests,
29
+ )
30
+
31
+ for name in _CPA_ENV_VARS:
32
+ os.environ.pop(name, None)
33
+ reset_catalog_cache_for_tests()
34
+ reset_fixture_root_for_tests()
35
+ yield
36
+ for name in _CPA_ENV_VARS:
37
+ os.environ.pop(name, None)
38
+ reset_catalog_cache_for_tests()
39
+ reset_fixture_root_for_tests()
@@ -198,3 +198,72 @@ def test_list_cli_shows_table(cache_root: Path) -> None:
198
198
  text = _out(result)
199
199
  assert "demo" in text
200
200
  assert "SHA" in text
201
+
202
+
203
+ def test_cache_list_verify_doctor_json(
204
+ cache_root: Path, monkeypatch: pytest.MonkeyPatch
205
+ ) -> None:
206
+ import json
207
+
208
+ entry = cache_root / "repos" / "demo"
209
+ sha = _init_git_repo(entry)
210
+ write_cache_meta(
211
+ entry,
212
+ CacheMeta(
213
+ url="https://example.com/repo.git",
214
+ ref="main",
215
+ fetched_at=time.time(),
216
+ commit=sha,
217
+ ),
218
+ )
219
+ monkeypatch.setattr(
220
+ "create_awesome_python_app.cache._probe_network",
221
+ lambda: __import__(
222
+ "create_awesome_python_app.cache", fromlist=["DoctorResult"]
223
+ ).DoctorResult(check="network", ok=True, detail="mocked"),
224
+ )
225
+
226
+ listed = runner.invoke(cache_app, ["list", "--json"])
227
+ assert listed.exit_code == 0, _out(listed)
228
+ payload = json.loads(listed.stdout)
229
+ assert payload[0]["id"] == "demo"
230
+ assert payload[0]["commit"] == sha
231
+
232
+ verified = runner.invoke(cache_app, ["verify", "--json"])
233
+ assert verified.exit_code == 0, _out(verified)
234
+ assert json.loads(verified.stdout)[0]["fsck_ok"] is True
235
+
236
+ doctor = runner.invoke(cache_app, ["doctor", "--json"])
237
+ assert doctor.exit_code == 0, _out(doctor)
238
+ assert any(row["check"] == "git" for row in json.loads(doctor.stdout))
239
+
240
+ outdated = runner.invoke(cache_app, ["outdated", "--json"])
241
+ assert outdated.exit_code == 0, _out(outdated)
242
+ assert isinstance(json.loads(outdated.stdout), list)
243
+
244
+
245
+ def test_cache_clean_json_and_force(cache_root: Path) -> None:
246
+ import json
247
+
248
+ entry = cache_root / "repos" / "demo"
249
+ _init_git_repo(entry)
250
+
251
+ blocked = runner.invoke(cache_app, ["clean"])
252
+ assert blocked.exit_code == 0
253
+ assert "Non-interactive" in _out(blocked)
254
+ assert entry.exists()
255
+
256
+ forced = runner.invoke(cache_app, ["clean", "--json"])
257
+ assert forced.exit_code == 0, _out(forced)
258
+ payload = json.loads(forced.stdout)
259
+ assert str(entry) in payload["removed"]
260
+ assert not entry.exists()
261
+
262
+
263
+ def test_cache_clean_force_without_json(cache_root: Path) -> None:
264
+ entry = cache_root / "repos" / "demo"
265
+ _init_git_repo(entry)
266
+ result = runner.invoke(cache_app, ["clean", "--force"])
267
+ assert result.exit_code == 0, _out(result)
268
+ assert "Removed" in _out(result)
269
+ assert not entry.exists()
@@ -0,0 +1,21 @@
1
+ from create_awesome_python_app.cli import app
2
+ from typer.testing import CliRunner
3
+
4
+ runner = CliRunner()
5
+
6
+
7
+ def _out(result) -> str:
8
+ return (result.stdout or "") + (result.stderr or "")
9
+
10
+
11
+ def test_list_templates() -> None:
12
+ result = runner.invoke(app, ["--list-templates"])
13
+ assert result.exit_code == 0
14
+ text = _out(result)
15
+ assert "Available Templates" in text
16
+
17
+
18
+ def test_list_addons() -> None:
19
+ result = runner.invoke(app, ["--list-addons"])
20
+ assert result.exit_code == 0
21
+ assert "Available Addons" in _out(result)
@@ -13,6 +13,9 @@ from create_awesome_python_app.catalog import (
13
13
  catalog_url,
14
14
  get_catalog_data,
15
15
  reset_catalog_cache_for_tests,
16
+ reset_fixture_root_for_tests,
17
+ resolve_fixture_root,
18
+ set_fixture_root_for_tests,
16
19
  )
17
20
 
18
21
  FIXTURE_PATH = (
@@ -21,9 +24,15 @@ FIXTURE_PATH = (
21
24
 
22
25
 
23
26
  @pytest.fixture(autouse=True)
24
- def _reset_cache(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
27
+ def _reset_cache(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
25
28
  reset_catalog_cache_for_tests()
29
+ reset_fixture_root_for_tests()
26
30
  monkeypatch.setenv("CPA_CACHE_DIR", str(tmp_path / "cache"))
31
+ monkeypatch.delenv("CPA_FIXTURE_DIR", raising=False)
32
+ monkeypatch.delenv("CPA_CATALOG_FIXTURE", raising=False)
33
+ yield
34
+ reset_fixture_root_for_tests()
35
+ reset_catalog_cache_for_tests()
27
36
 
28
37
 
29
38
  def test_default_catalog_url(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -83,3 +92,33 @@ def test_get_catalog_data_disk_fallback_on_network_error(
83
92
 
84
93
  def test_default_url_points_to_cpa_templates() -> None:
85
94
  assert DEFAULT_CATALOG_URL.endswith("/cpa-templates/main/templates.json")
95
+
96
+
97
+ def test_resolve_fixture_root_respects_env(
98
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
99
+ ) -> None:
100
+ monkeypatch.setenv("CPA_FIXTURE_DIR", str(tmp_path))
101
+ assert resolve_fixture_root() == tmp_path.resolve()
102
+
103
+
104
+ def test_get_catalog_data_fixture_uses_custom_dir(
105
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
106
+ ) -> None:
107
+ catalog_dir = tmp_path / "fixtures" / "catalog"
108
+ catalog_dir.mkdir(parents=True)
109
+ payload = {
110
+ "templates": [{"slug": "from-env-fixture"}],
111
+ "extensions": [],
112
+ "categories": [],
113
+ }
114
+ (catalog_dir / "templates.json").write_text(json.dumps(payload), encoding="utf-8")
115
+ monkeypatch.setenv("CPA_CATALOG_FIXTURE", "1")
116
+ monkeypatch.setenv("CPA_FIXTURE_DIR", str(tmp_path))
117
+ data = get_catalog_data(force_refresh=True)
118
+ assert data["templates"][0]["slug"] == "from-env-fixture"
119
+
120
+
121
+ def test_set_fixture_root_for_tests(tmp_path: Path) -> None:
122
+ set_fixture_root_for_tests(tmp_path)
123
+ assert resolve_fixture_root() == tmp_path
124
+ reset_fixture_root_for_tests()
@@ -307,3 +307,32 @@ def test_find_incompatible_pairs_dedupes_symmetric_edges() -> None:
307
307
  }
308
308
  pairs = find_incompatible_pairs(["a", "b"], catalog=catalog)
309
309
  assert pairs == [("a", "b")]
310
+
311
+
312
+ def test_category_index_includes_rich_metadata() -> None:
313
+ from create_awesome_python_app.catalog import (
314
+ category_index,
315
+ format_category_choice_title,
316
+ )
317
+
318
+ catalog = {
319
+ "categories": [
320
+ {
321
+ "slug": "backend-applications",
322
+ "name": "Backend Applications",
323
+ "description": "API starters",
324
+ "details": "FastAPI and friends",
325
+ "labels": ["Backend", "API"],
326
+ }
327
+ ],
328
+ "templates": [],
329
+ "extensions": [],
330
+ }
331
+ info = category_index(catalog)["backend-applications"]
332
+ assert info.description == "API starters"
333
+ assert info.details == "FastAPI and friends"
334
+ assert info.labels == ("Backend", "API")
335
+ title = format_category_choice_title(info, 2)
336
+ assert "Backend Applications (2 extensions)" in title
337
+ assert "API starters" in title
338
+ assert "Backend" in title
@@ -1,3 +1,4 @@
1
+ import os
1
2
  from pathlib import Path
2
3
 
3
4
  import pytest
@@ -6,16 +7,6 @@ from create_awesome_python_app.cli import app
6
7
  from typer.testing import CliRunner
7
8
 
8
9
  runner = CliRunner()
9
- _CPA_ENV_VARS = ("CPA_REFRESH", "CPA_NO_CATALOG_CACHE", "CPA_CACHE_DIR")
10
-
11
-
12
- @pytest.fixture(autouse=True)
13
- def _clean_cpa_env(monkeypatch):
14
- for name in _CPA_ENV_VARS:
15
- monkeypatch.delenv(name, raising=False)
16
- yield
17
- for name in _CPA_ENV_VARS:
18
- monkeypatch.delenv(name, raising=False)
19
10
 
20
11
 
21
12
  def test_version() -> None:
@@ -223,3 +214,124 @@ def test_incompatible_addons_fail_fast(tmp_path: Path, monkeypatch) -> None:
223
214
  assert result.exit_code == 2
224
215
  assert "Incompatible extension combination" in text
225
216
  assert "saga" in text
217
+
218
+
219
+ def test_help_mentions_fixture() -> None:
220
+ result = runner.invoke(app, ["--help"])
221
+ assert result.exit_code == 0
222
+ text = (result.stdout or "") + (result.stderr or "")
223
+ assert "fixture" in text.lower()
224
+
225
+
226
+ def test_options_after_project_directory(tmp_path: Path, monkeypatch) -> None:
227
+ """Regression: README-style `cpa my-api --template …` must work.
228
+
229
+ Registering a Typer subcommand group made Click treat ``--template`` as a
230
+ COMMAND after the positional project directory.
231
+ """
232
+ tpl = tmp_path / "tpl"
233
+ tpl.mkdir()
234
+ captured: dict[str, object] = {}
235
+
236
+ async def fake_check_for_latest_version(_package_name):
237
+ return None
238
+
239
+ async def fake_create_python_app(project_directory, options, *_args, **_kwargs):
240
+ captured["project_directory"] = project_directory
241
+ captured["options"] = options
242
+
243
+ monkeypatch.setattr(
244
+ "create_awesome_python_app.cli.check_for_latest_version",
245
+ fake_check_for_latest_version,
246
+ )
247
+ monkeypatch.setattr(
248
+ "create_awesome_python_app.cli.create_python_app",
249
+ fake_create_python_app,
250
+ )
251
+
252
+ result = runner.invoke(
253
+ app,
254
+ [
255
+ "my-api",
256
+ "--template",
257
+ f"file://{tpl}",
258
+ "--addons",
259
+ "github-setup",
260
+ "--no-install",
261
+ "--no-interactive",
262
+ ],
263
+ )
264
+
265
+ text = (result.stdout or "") + (result.stderr or "")
266
+ assert result.exit_code == 0, text
267
+ assert "No such command" not in text
268
+ assert captured["project_directory"] == "my-api"
269
+ options = captured["options"]
270
+ assert isinstance(options, dict)
271
+ assert options["template"] == f"file://{tpl}"
272
+
273
+
274
+ def test_preprocess_fixture_argv_bare_and_with_dir() -> None:
275
+ from create_awesome_python_app.cli import _FIXTURE_AUTO, _preprocess_fixture_argv
276
+
277
+ assert _preprocess_fixture_argv(["cpa", "--fixture", "--list-templates"]) == [
278
+ "cpa",
279
+ f"--fixture={_FIXTURE_AUTO}",
280
+ "--list-templates",
281
+ ]
282
+ assert _preprocess_fixture_argv(
283
+ ["cpa", "--fixture", "./my-fixtures", "--list-templates"]
284
+ ) == ["cpa", "--fixture", "./my-fixtures", "--list-templates"]
285
+
286
+
287
+ def test_fixture_flag_enables_catalog_fixture(
288
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
289
+ ) -> None:
290
+ from create_awesome_python_app.cli import _FIXTURE_AUTO, apply_fixture_mode
291
+
292
+ apply_fixture_mode(_FIXTURE_AUTO)
293
+ assert os.environ.get("CPA_CATALOG_FIXTURE") == "1"
294
+ assert "CPA_FIXTURE_DIR" not in os.environ
295
+
296
+ monkeypatch.delenv("CPA_CATALOG_FIXTURE", raising=False)
297
+ monkeypatch.delenv("CPA_FIXTURE_DIR", raising=False)
298
+ apply_fixture_mode(str(tmp_path))
299
+ assert os.environ.get("CPA_CATALOG_FIXTURE") == "1"
300
+ assert os.environ.get("CPA_FIXTURE_DIR") == str(tmp_path)
301
+
302
+
303
+ def test_list_templates_with_fixture_dir(
304
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
305
+ ) -> None:
306
+ import json
307
+ import os
308
+
309
+ catalog_dir = tmp_path / "fixtures" / "catalog"
310
+ catalog_dir.mkdir(parents=True)
311
+ (catalog_dir / "templates.json").write_text(
312
+ json.dumps(
313
+ {
314
+ "templates": [
315
+ {
316
+ "slug": "fixture-only",
317
+ "category": "tooling",
318
+ "type": "cli",
319
+ }
320
+ ],
321
+ "extensions": [],
322
+ "categories": [],
323
+ }
324
+ ),
325
+ encoding="utf-8",
326
+ )
327
+ monkeypatch.delenv("CPA_CATALOG_FIXTURE", raising=False)
328
+ monkeypatch.delenv("CPA_FIXTURE_DIR", raising=False)
329
+
330
+ result = runner.invoke(
331
+ app,
332
+ ["--fixture", str(tmp_path), "--list-templates"],
333
+ )
334
+ text = (result.stdout or "") + (result.stderr or "")
335
+ assert result.exit_code == 0, text
336
+ assert "fixture-only" in text
337
+ assert os.environ.get("CPA_CATALOG_FIXTURE") == "1"
@@ -21,6 +21,19 @@ def _cpa_templates_available() -> bool:
21
21
  return (FASTAPI_TEMPLATE / "pyproject.toml").is_file()
22
22
 
23
23
 
24
+ def _clean_fixture_env(monkeypatch: pytest.MonkeyPatch) -> None:
25
+ """Ensure subprocess scaffolds are not forced into fixture mode."""
26
+ monkeypatch.delenv("CPA_CATALOG_FIXTURE", raising=False)
27
+ monkeypatch.delenv("CPA_FIXTURE_DIR", raising=False)
28
+
29
+
30
+ def _subprocess_env() -> dict[str, str]:
31
+ env = os.environ.copy()
32
+ env.pop("CPA_CATALOG_FIXTURE", None)
33
+ env.pop("CPA_FIXTURE_DIR", None)
34
+ return env
35
+
36
+
24
37
  @pytest.mark.skipif(
25
38
  not _cpa_templates_available(),
26
39
  reason="cpa-templates checkout not available (set CPA_TEMPLATES_ROOT)",
@@ -28,6 +41,7 @@ def _cpa_templates_available() -> bool:
28
41
  def test_scaffold_fastapi_starter_from_cpa_templates(
29
42
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch
30
43
  ) -> None:
44
+ _clean_fixture_env(monkeypatch)
31
45
  monkeypatch.setenv("CI", "1")
32
46
  monkeypatch.setenv("CPA_SKIP_GIT", "1")
33
47
  monkeypatch.setenv("CPA_CACHE_DIR", str(tmp_path / "cpa-cache"))
@@ -48,6 +62,7 @@ def test_scaffold_fastapi_starter_from_cpa_templates(
48
62
  cwd=_REPO_ROOT,
49
63
  capture_output=True,
50
64
  text=True,
65
+ env=_subprocess_env(),
51
66
  check=False,
52
67
  )
53
68
  assert result.returncode == 0, result.stdout + result.stderr
@@ -78,6 +93,7 @@ def test_scaffold_fastapi_starter_via_catalog_slug(
78
93
  """Scaffold using --template fastapi-starter slug (issue #160 / #161)."""
79
94
  import json
80
95
 
96
+ _clean_fixture_env(monkeypatch)
81
97
  monkeypatch.setenv("CI", "1")
82
98
  monkeypatch.setenv("CPA_SKIP_GIT", "1")
83
99
  monkeypatch.setenv("CPA_CACHE_DIR", str(tmp_path / "cpa-cache"))
@@ -114,6 +130,7 @@ def test_scaffold_fastapi_starter_via_catalog_slug(
114
130
  cwd=_REPO_ROOT,
115
131
  capture_output=True,
116
132
  text=True,
133
+ env=_subprocess_env(),
117
134
  check=False,
118
135
  )
119
136
  assert result.returncode == 0, result.stdout + result.stderr
@@ -129,6 +146,7 @@ def test_scaffold_via_catalog_addon_slug(
129
146
  ) -> None:
130
147
  import json
131
148
 
149
+ _clean_fixture_env(monkeypatch)
132
150
  monkeypatch.setenv("CI", "1")
133
151
  monkeypatch.setenv("CPA_SKIP_GIT", "1")
134
152
  monkeypatch.setenv("CPA_CACHE_DIR", str(tmp_path / "cpa-cache"))
@@ -170,6 +188,7 @@ def test_scaffold_via_catalog_addon_slug(
170
188
  cwd=_REPO_ROOT,
171
189
  capture_output=True,
172
190
  text=True,
191
+ env=_subprocess_env(),
173
192
  check=False,
174
193
  )
175
194
  assert result.returncode == 0, result.stdout + result.stderr
@@ -183,6 +202,7 @@ def test_scaffold_via_catalog_addon_slug(
183
202
  def test_scaffold_fastapi_with_github_setup_extension(
184
203
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch
185
204
  ) -> None:
205
+ _clean_fixture_env(monkeypatch)
186
206
  monkeypatch.setenv("CI", "1")
187
207
  monkeypatch.setenv("CPA_SKIP_GIT", "1")
188
208
  monkeypatch.setenv("CPA_CACHE_DIR", str(tmp_path / "cpa-cache"))
@@ -204,6 +224,7 @@ def test_scaffold_fastapi_with_github_setup_extension(
204
224
  cwd=_REPO_ROOT,
205
225
  capture_output=True,
206
226
  text=True,
227
+ env=_subprocess_env(),
207
228
  check=False,
208
229
  )
209
230
  assert result.returncode == 0, result.stdout + result.stderr
@@ -1,13 +0,0 @@
1
- from create_awesome_python_app.cli import app
2
- from typer.testing import CliRunner
3
-
4
- runner = CliRunner()
5
-
6
-
7
- def test_list_templates() -> None:
8
- result = runner.invoke(app, ["--list-templates"])
9
- assert result.exit_code == 0
10
-
11
-
12
- def test_list_addons() -> None:
13
- assert runner.invoke(app, ["--list-addons"]).exit_code == 0