create-awesome-python-app 0.2.6__tar.gz → 0.2.8__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 (23) hide show
  1. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.8}/PKG-INFO +2 -1
  2. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.8}/README.md +1 -0
  3. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.8}/pyproject.toml +1 -1
  4. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.8}/src/create_awesome_python_app/__init__.py +1 -1
  5. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.8}/src/create_awesome_python_app/catalog.py +219 -64
  6. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.8}/src/create_awesome_python_app/cli.py +193 -15
  7. create_awesome_python_app-0.2.8/tests/conftest.py +39 -0
  8. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.8}/tests/test_cache_cmds.py +69 -0
  9. create_awesome_python_app-0.2.8/tests/test_catalog.py +21 -0
  10. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.8}/tests/test_catalog_fetch.py +40 -1
  11. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.8}/tests/test_catalog_resolve.py +29 -0
  12. create_awesome_python_app-0.2.8/tests/test_cli.py +439 -0
  13. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.8}/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/tests/test_cli.py +0 -225
  16. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.8}/.gitignore +0 -0
  17. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.8}/assets/hero.svg +0 -0
  18. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.8}/src/create_awesome_python_app/cache.py +0 -0
  19. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.8}/src/create_awesome_python_app/prompt_style.py +0 -0
  20. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.8}/tests/test_cache_env.py +0 -0
  21. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.8}/tests/test_flags.py +0 -0
  22. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.8}/tests/test_interactive.py +0 -0
  23. {create_awesome_python_app-0.2.6 → create_awesome_python_app-0.2.8}/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.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
 
@@ -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.8"
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.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)}")