create-awesome-python-app 0.2.2__tar.gz → 0.2.4__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 (20) hide show
  1. {create_awesome_python_app-0.2.2 → create_awesome_python_app-0.2.4}/PKG-INFO +2 -2
  2. {create_awesome_python_app-0.2.2 → create_awesome_python_app-0.2.4}/pyproject.toml +2 -2
  3. {create_awesome_python_app-0.2.2 → create_awesome_python_app-0.2.4}/src/create_awesome_python_app/__init__.py +1 -1
  4. {create_awesome_python_app-0.2.2 → create_awesome_python_app-0.2.4}/src/create_awesome_python_app/catalog.py +27 -15
  5. {create_awesome_python_app-0.2.2 → create_awesome_python_app-0.2.4}/src/create_awesome_python_app/cli.py +27 -11
  6. create_awesome_python_app-0.2.4/src/create_awesome_python_app/prompt_style.py +113 -0
  7. {create_awesome_python_app-0.2.2 → create_awesome_python_app-0.2.4}/tests/test_catalog_resolve.py +35 -11
  8. {create_awesome_python_app-0.2.2 → create_awesome_python_app-0.2.4}/tests/test_interactive.py +8 -8
  9. {create_awesome_python_app-0.2.2 → create_awesome_python_app-0.2.4}/.gitignore +0 -0
  10. {create_awesome_python_app-0.2.2 → create_awesome_python_app-0.2.4}/README.md +0 -0
  11. {create_awesome_python_app-0.2.2 → create_awesome_python_app-0.2.4}/assets/hero.svg +0 -0
  12. {create_awesome_python_app-0.2.2 → create_awesome_python_app-0.2.4}/src/create_awesome_python_app/cache.py +0 -0
  13. {create_awesome_python_app-0.2.2 → create_awesome_python_app-0.2.4}/tests/test_cache_cmds.py +0 -0
  14. {create_awesome_python_app-0.2.2 → create_awesome_python_app-0.2.4}/tests/test_cache_env.py +0 -0
  15. {create_awesome_python_app-0.2.2 → create_awesome_python_app-0.2.4}/tests/test_catalog.py +0 -0
  16. {create_awesome_python_app-0.2.2 → create_awesome_python_app-0.2.4}/tests/test_catalog_fetch.py +0 -0
  17. {create_awesome_python_app-0.2.2 → create_awesome_python_app-0.2.4}/tests/test_cli.py +0 -0
  18. {create_awesome_python_app-0.2.2 → create_awesome_python_app-0.2.4}/tests/test_cpa_templates_integration.py +0 -0
  19. {create_awesome_python_app-0.2.2 → create_awesome_python_app-0.2.4}/tests/test_flags.py +0 -0
  20. {create_awesome_python_app-0.2.2 → create_awesome_python_app-0.2.4}/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.2
3
+ Version: 0.2.4
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
@@ -8,7 +8,7 @@ Project-URL: Issues, https://github.com/Create-Python-App/create-python-app/issu
8
8
  Project-URL: Changelog, https://github.com/Create-Python-App/create-python-app/blob/main/CHANGELOG.md
9
9
  License-Expression: MIT
10
10
  Requires-Python: >=3.12
11
- Requires-Dist: create-python-app-core>=0.2.2
11
+ Requires-Dist: create-python-app-core>=0.2.4
12
12
  Requires-Dist: questionary>=2.1.1
13
13
  Requires-Dist: rich>=15.0.0
14
14
  Requires-Dist: typer>=0.27.0
@@ -1,12 +1,12 @@
1
1
  [project]
2
2
  name = "create-awesome-python-app"
3
- version = "0.2.2"
3
+ version = "0.2.4"
4
4
  description = "Composable scaffolding CLI for production-ready Python apps"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
7
7
  license = "MIT"
8
8
  dependencies = [
9
- "create-python-app-core>=0.2.2",
9
+ "create-python-app-core>=0.2.4",
10
10
  "questionary>=2.1.1",
11
11
  "rich>=15.0.0",
12
12
  "typer>=0.27.0",
@@ -1,3 +1,3 @@
1
1
  """Create Awesome Python App CLI."""
2
2
 
3
- __version__ = "0.2.2"
3
+ __version__ = "0.2.4"
@@ -16,6 +16,10 @@ from rich.console import Console
16
16
  from rich.table import Table
17
17
 
18
18
  from create_awesome_python_app import __version__
19
+ from create_awesome_python_app.prompt_style import (
20
+ custom_template_title,
21
+ template_title_tokens,
22
+ )
19
23
 
20
24
  console = Console(stderr=True)
21
25
 
@@ -26,7 +30,7 @@ CUSTOM_TEMPLATE_SENTINEL = "__custom_template__"
26
30
  class TemplateChoice:
27
31
  """Searchable interactive template choice."""
28
32
 
29
- title: str
33
+ title: Any
30
34
  value: str
31
35
  search: str
32
36
 
@@ -235,32 +239,40 @@ def build_template_choices(data: dict[str, Any]) -> list[TemplateChoice]:
235
239
  category_name = categories.get(category_slug, category_slug)
236
240
  badge = short_category_label(category_name).ljust(10)[:10]
237
241
  slug = str(template.get("slug", ""))
238
- labels = template.get("labels", [])
239
- label_suffix = ""
240
- if isinstance(labels, list) and labels:
241
- label_suffix = " · " + ", ".join(str(label) for label in labels[:3])
242
+ labels_raw = template.get("labels", [])
243
+ labels = (
244
+ [str(label) for label in labels_raw[:3]]
245
+ if isinstance(labels_raw, list)
246
+ else []
247
+ )
242
248
  description = str(template.get("description", "")).strip()
243
- description_suffix = f" — {description}" if description else ""
244
- # Plain text only: questionary.autocomplete wraps choices in HTML for
245
- # match highlighting, so ANSI / markup here raises XML parse errors.
246
- title = (
247
- f"{badge} "
248
- f"{template.get('name', slug)} ({slug})"
249
- f"{label_suffix}{description_suffix}"
249
+ name = str(template.get("name", slug))
250
+ search = _search_text(template, category_name)
251
+ # FormattedText tokens (not raw ANSI): select() prints str titles
252
+ # literally, which showed ^[[1;94m… in terminals.
253
+ title = template_title_tokens(
254
+ category_slug=category_slug,
255
+ badge=badge,
256
+ name=name,
257
+ slug=slug,
258
+ labels=labels,
259
+ description=description,
260
+ search=search,
250
261
  )
251
262
  choices.append(
252
263
  TemplateChoice(
253
264
  title=title,
254
265
  value=template_url,
255
- search=_search_text(template, category_name),
266
+ search=search,
256
267
  )
257
268
  )
258
269
 
270
+ custom_search = "custom own template url github file"
259
271
  choices.append(
260
272
  TemplateChoice(
261
- title=" " * 12 + "Use my own template URL",
273
+ title=custom_template_title(custom_search),
262
274
  value=CUSTOM_TEMPLATE_SENTINEL,
263
- search="custom own template url github file",
275
+ search=custom_search,
264
276
  )
265
277
  )
266
278
  return choices
@@ -104,6 +104,8 @@ def _prompt_custom_options(
104
104
  ) -> dict[str, str]:
105
105
  import questionary
106
106
 
107
+ from create_awesome_python_app.prompt_style import CPA_PROMPT_STYLE
108
+
107
109
  source = resolve_source(template, cache_dir=cache_dir)
108
110
  root = download_repository(
109
111
  source,
@@ -138,9 +140,12 @@ def _prompt_custom_options(
138
140
  answer = questionary.confirm(
139
141
  message,
140
142
  default=initial.lower() in {"1", "true", "yes", "on"},
143
+ style=CPA_PROMPT_STYLE,
141
144
  ).ask()
142
145
  else:
143
- answer = questionary.text(message, default=initial).ask()
146
+ answer = questionary.text(
147
+ message, default=initial, style=CPA_PROMPT_STYLE
148
+ ).ask()
144
149
  if answer is None:
145
150
  raise typer.Exit(1)
146
151
  answers[option.key] = _stringify_option_value(answer)
@@ -230,30 +235,38 @@ def scaffold(
230
235
  if want_interactive and not template:
231
236
  try:
232
237
  import questionary
238
+ from questionary import Choice
233
239
 
234
240
  from create_awesome_python_app.catalog import (
235
241
  CUSTOM_TEMPLATE_SENTINEL,
236
242
  build_template_choices,
237
243
  get_catalog_data,
238
244
  )
245
+ from create_awesome_python_app.prompt_style import CPA_PROMPT_STYLE
239
246
 
240
247
  interactive_catalog = get_catalog_data()
241
248
  template_choices = build_template_choices(interactive_catalog)
242
- choice_by_title = {
243
- choice.title: choice.value for choice in template_choices
244
- }
245
- selected_title = questionary.autocomplete(
246
- "Pick a template (type to search)",
247
- choices=list(choice_by_title),
248
- match_middle=True,
249
+ # select + type-to-filter: browseable list (CNA-style discovery)
250
+ # instead of autocomplete-only. use_jk_keys must be False with search.
251
+ selected_template = questionary.select(
252
+ "Pick a template",
253
+ choices=[
254
+ Choice(title=choice.title, value=choice.value)
255
+ for choice in template_choices
256
+ ],
249
257
  qmark="?",
258
+ pointer="❯",
259
+ style=CPA_PROMPT_STYLE,
260
+ use_search_filter=True,
261
+ use_jk_keys=False,
262
+ instruction="(↑↓ browse · type to filter · Enter)",
250
263
  ).ask()
251
- selected_template = choice_by_title.get(str(selected_title), selected_title)
252
264
  if selected_template == CUSTOM_TEMPLATE_SENTINEL:
253
265
  selected_template = questionary.text(
254
266
  "Template URL",
255
267
  default="file://.",
256
268
  validate=lambda value: bool(value) or "Template URL is required",
269
+ style=CPA_PROMPT_STYLE,
257
270
  ).ask()
258
271
  template = selected_template
259
272
  if not template:
@@ -296,6 +309,7 @@ def scaffold(
296
309
  get_catalog_data,
297
310
  group_extension_choices,
298
311
  )
312
+ from create_awesome_python_app.prompt_style import CPA_PROMPT_STYLE
299
313
 
300
314
  interactive_catalog = interactive_catalog or get_catalog_data()
301
315
  extension_choices = build_extension_choices(interactive_catalog, template)
@@ -316,7 +330,8 @@ def scaffold(
316
330
  "Which kinds of extensions do you need?",
317
331
  choices=category_choices,
318
332
  qmark="?",
319
- pointer=">",
333
+ pointer="",
334
+ style=CPA_PROMPT_STYLE,
320
335
  ).ask()
321
336
  selected_addons: list[str] = []
322
337
  for category_slug in selected_categories or []:
@@ -330,7 +345,8 @@ def scaffold(
330
345
  for choice in choices
331
346
  ],
332
347
  qmark="?",
333
- pointer=">",
348
+ pointer="",
349
+ style=CPA_PROMPT_STYLE,
334
350
  ).ask()
335
351
  selected_addons.extend(str(item) for item in picked or [])
336
352
  addons = selected_addons
@@ -0,0 +1,113 @@
1
+ """High-contrast questionary styles for CPA interactive prompts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any
7
+
8
+ from questionary import Style
9
+
10
+ # Lighter brand blues/greens than docs hex so selected rows stay readable on
11
+ # dark terminals (slate backgrounds common in Arch/Ghostty/Alacritty).
12
+ CPA_PROMPT_STYLE = Style.from_dict(
13
+ {
14
+ "qmark": "fg:#60a5fa bold",
15
+ "question": "bold fg:#f8fafc",
16
+ "answer": "fg:#4ade80 bold",
17
+ "pointer": "fg:#60a5fa bold",
18
+ "highlighted": "fg:#0f172a bg:#60a5fa bold",
19
+ "selected": "fg:#4ade80 bold",
20
+ "separator": "fg:#94a3b8",
21
+ "instruction": "fg:#94a3b8",
22
+ "text": "fg:#e2e8f0",
23
+ "disabled": "fg:#64748b italic",
24
+ "checkbox": "fg:#60a5fa",
25
+ "checkbox-selected": "fg:#4ade80 bold",
26
+ }
27
+ )
28
+
29
+ # Bright prompt_toolkit style strings (not raw ANSI — select() prints str titles
30
+ # literally, so escapes show as ^[[...m unless titles are FormattedText tokens).
31
+ _CATEGORY_STYLES = (
32
+ "fg:#facc15 bold", # yellow
33
+ "fg:#4ade80 bold", # green
34
+ "fg:#22d3ee bold", # cyan
35
+ "fg:#e879f9 bold", # magenta
36
+ "fg:#60a5fa bold", # blue
37
+ )
38
+
39
+
40
+ class SearchableFormattedText(list):
41
+ """FormattedText tokens with ``.lower()`` for questionary search filter.
42
+
43
+ ``select(use_search_filter=True)`` does ``needle in choice.title.lower()``.
44
+ A plain token list has no ``.lower()``; this keeps filter working while
45
+ titles render as styled FormattedText.
46
+ """
47
+
48
+ def __init__(self, tokens: list[tuple[str, str]], search: str) -> None:
49
+ super().__init__(tokens)
50
+ self._search = search
51
+
52
+ def lower(self) -> str:
53
+ return self._search.lower()
54
+
55
+
56
+ def colors_enabled() -> bool:
57
+ return not os.environ.get("NO_COLOR")
58
+
59
+
60
+ def category_style(slug: str) -> str:
61
+ idx = sum(ord(char) for char in slug) % len(_CATEGORY_STYLES)
62
+ return _CATEGORY_STYLES[idx]
63
+
64
+
65
+ def plain_title_text(title: Any) -> str:
66
+ """Join FormattedText token text (or return a plain string title)."""
67
+ if isinstance(title, list):
68
+ return "".join(str(token[1]) for token in title)
69
+ return str(title)
70
+
71
+
72
+ def template_title_tokens(
73
+ *,
74
+ category_slug: str,
75
+ badge: str,
76
+ name: str,
77
+ slug: str,
78
+ labels: list[str],
79
+ description: str,
80
+ search: str,
81
+ ) -> SearchableFormattedText | str:
82
+ """Build a select-safe title: FormattedText when colors on, else plain str."""
83
+ label_suffix = ""
84
+ if labels:
85
+ label_suffix = " · " + ", ".join(labels[:3])
86
+ description_suffix = f" — {description}" if description else ""
87
+ plain = f"{badge} {name} ({slug}){label_suffix}{description_suffix}"
88
+
89
+ if not colors_enabled():
90
+ return plain
91
+
92
+ tokens: list[tuple[str, str]] = [
93
+ (category_style(category_slug), badge),
94
+ ("", " "),
95
+ ("bold", name),
96
+ ("class:instruction", f" ({slug})"),
97
+ ]
98
+ if label_suffix:
99
+ tokens.append(("class:instruction", label_suffix))
100
+ if description_suffix:
101
+ tokens.append(("fg:#94a3b8", description_suffix))
102
+ return SearchableFormattedText(tokens, search=search or plain)
103
+
104
+
105
+ def custom_template_title(search: str) -> SearchableFormattedText | str:
106
+ label = "Use my own template URL"
107
+ plain = " " * 12 + label
108
+ if not colors_enabled():
109
+ return plain
110
+ return SearchableFormattedText(
111
+ [("", " " * 12), ("italic fg:#94a3b8", label)],
112
+ search=search or plain,
113
+ )
@@ -17,6 +17,10 @@ from create_awesome_python_app.catalog import (
17
17
  short_category_label,
18
18
  validate_extension_compatibility,
19
19
  )
20
+ from create_awesome_python_app.prompt_style import (
21
+ SearchableFormattedText,
22
+ plain_title_text,
23
+ )
20
24
 
21
25
  SAMPLE_CATALOG = {
22
26
  "templates": [
@@ -99,20 +103,41 @@ def test_build_template_choices_are_searchable() -> None:
99
103
  choices = build_template_choices(catalog)
100
104
  first = choices[0]
101
105
  assert first.value == "file:///templates/fastapi"
102
- assert "FastAPI Starter" in first.title
103
- assert "OpenAPI" in first.title
104
- assert "uv" in first.title
105
- assert "\033" not in first.title
106
+ title_text = plain_title_text(first.title)
107
+ assert "FastAPI Starter" in title_text
108
+ assert "OpenAPI" in title_text
109
+ assert "uv" in title_text
106
110
  assert "openapi" in first.search
107
111
  assert "backend" in first.search
108
112
  assert "uv" in first.search
109
113
  assert choices[-1].value == CUSTOM_TEMPLATE_SENTINEL
110
114
 
111
115
 
112
- def test_template_choice_titles_are_html_safe_for_questionary() -> None:
113
- """questionary.autocomplete formats choice text as HTML (match underline)."""
114
- from prompt_toolkit.formatted_text import HTML
116
+ def test_template_choice_titles_use_formatted_text(monkeypatch) -> None:
117
+ """select() needs FormattedText tokens; raw ANSI shows as ^[[…m."""
118
+ monkeypatch.delenv("NO_COLOR", raising=False)
119
+ catalog = {
120
+ "categories": [
121
+ {"slug": "backend-applications", "name": "Backend Applications"}
122
+ ],
123
+ "templates": [
124
+ {
125
+ "slug": "fastapi-starter",
126
+ "name": "FastAPI Starter",
127
+ "url": "file:///templates/fastapi",
128
+ "category": "backend-applications",
129
+ }
130
+ ],
131
+ }
132
+ title = build_template_choices(catalog)[0].title
133
+ assert isinstance(title, SearchableFormattedText)
134
+ assert "\033" not in plain_title_text(title)
135
+ assert "FastAPI Starter" in plain_title_text(title)
136
+ assert "fastapi" in title.lower()
115
137
 
138
+
139
+ def test_template_choice_titles_respect_no_color(monkeypatch) -> None:
140
+ monkeypatch.setenv("NO_COLOR", "1")
116
141
  catalog = {
117
142
  "categories": [
118
143
  {"slug": "backend-applications", "name": "Backend Applications"}
@@ -121,16 +146,15 @@ def test_template_choice_titles_are_html_safe_for_questionary() -> None:
121
146
  {
122
147
  "slug": "fastapi-starter",
123
148
  "name": "FastAPI Starter",
124
- "description": "Async API with OpenAPI docs",
125
149
  "url": "file:///templates/fastapi",
126
150
  "category": "backend-applications",
127
- "labels": ["FastAPI"],
128
151
  }
129
152
  ],
130
153
  }
131
154
  title = build_template_choices(catalog)[0].title
132
- # Must not raise "not well-formed (invalid token)" from ANSI escapes.
133
- HTML("{}<b><u>{}</u></b>{}").format(title[:3], title[3:6], title[6:])
155
+ assert isinstance(title, str)
156
+ assert "\033" not in title
157
+ assert "FastAPI Starter" in title
134
158
 
135
159
 
136
160
  def test_build_extension_choices_filters_by_template_type() -> None:
@@ -28,10 +28,10 @@ def test_in_ci_env(monkeypatch) -> None:
28
28
  os.environ.pop("CI", None)
29
29
 
30
30
 
31
- def test_interactive_template_autocomplete_omits_pointer(
31
+ def test_interactive_template_select_uses_search_filter(
32
32
  tmp_path: Path, monkeypatch
33
33
  ) -> None:
34
- """questionary.autocomplete rejects ``pointer`` (PromptSession TypeError)."""
34
+ """Template pick is a browsable select with type-to-filter (not autocomplete)."""
35
35
  template_dir = tmp_path / "fastapi"
36
36
  template_dir.mkdir()
37
37
  (template_dir / "cpa.config.json").write_text(
@@ -60,11 +60,9 @@ def test_interactive_template_autocomplete_omits_pointer(
60
60
 
61
61
  captured_kwargs: dict[str, object] = {}
62
62
 
63
- def fake_autocomplete(*_args, **kwargs):
63
+ def fake_select(*_args, **kwargs):
64
64
  captured_kwargs.update(kwargs)
65
- choices = kwargs.get("choices") or []
66
- # Return a mapped title so choice_by_title resolves to the template URL.
67
- return FakePrompt(choices[0] if choices else template_url)
65
+ return FakePrompt(template_url)
68
66
 
69
67
  def fake_checkbox(*_args, **_kwargs):
70
68
  return FakePrompt([])
@@ -75,7 +73,7 @@ def test_interactive_template_autocomplete_omits_pointer(
75
73
  captured["project_directory"] = project_directory
76
74
  captured["options"] = options
77
75
 
78
- monkeypatch.setattr("questionary.autocomplete", fake_autocomplete)
76
+ monkeypatch.setattr("questionary.select", fake_select)
79
77
  monkeypatch.setattr("questionary.checkbox", fake_checkbox)
80
78
  monkeypatch.setattr(
81
79
  "create_awesome_python_app.cli.create_python_app",
@@ -89,7 +87,9 @@ def test_interactive_template_autocomplete_omits_pointer(
89
87
  result = runner.invoke(app, ["--interactive", "--no-install", "api"])
90
88
 
91
89
  assert result.exit_code == 0, result.stdout + result.stderr
92
- assert "pointer" not in captured_kwargs
90
+ assert captured_kwargs.get("use_search_filter") is True
91
+ assert captured_kwargs.get("use_jk_keys") is False
92
+ assert captured_kwargs.get("style") is not None
93
93
  assert captured["project_directory"] == "api"
94
94
 
95
95