create-awesome-python-app 0.2.8__tar.gz → 0.2.10__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 (21) hide show
  1. {create_awesome_python_app-0.2.8 → create_awesome_python_app-0.2.10}/PKG-INFO +2 -2
  2. {create_awesome_python_app-0.2.8 → create_awesome_python_app-0.2.10}/pyproject.toml +2 -2
  3. {create_awesome_python_app-0.2.8 → create_awesome_python_app-0.2.10}/src/create_awesome_python_app/__init__.py +1 -1
  4. {create_awesome_python_app-0.2.8 → create_awesome_python_app-0.2.10}/src/create_awesome_python_app/cli.py +55 -19
  5. {create_awesome_python_app-0.2.8 → create_awesome_python_app-0.2.10}/tests/test_cli.py +192 -0
  6. {create_awesome_python_app-0.2.8 → create_awesome_python_app-0.2.10}/.gitignore +0 -0
  7. {create_awesome_python_app-0.2.8 → create_awesome_python_app-0.2.10}/README.md +0 -0
  8. {create_awesome_python_app-0.2.8 → create_awesome_python_app-0.2.10}/assets/hero.svg +0 -0
  9. {create_awesome_python_app-0.2.8 → create_awesome_python_app-0.2.10}/src/create_awesome_python_app/cache.py +0 -0
  10. {create_awesome_python_app-0.2.8 → create_awesome_python_app-0.2.10}/src/create_awesome_python_app/catalog.py +0 -0
  11. {create_awesome_python_app-0.2.8 → create_awesome_python_app-0.2.10}/src/create_awesome_python_app/prompt_style.py +0 -0
  12. {create_awesome_python_app-0.2.8 → create_awesome_python_app-0.2.10}/tests/conftest.py +0 -0
  13. {create_awesome_python_app-0.2.8 → create_awesome_python_app-0.2.10}/tests/test_cache_cmds.py +0 -0
  14. {create_awesome_python_app-0.2.8 → create_awesome_python_app-0.2.10}/tests/test_cache_env.py +0 -0
  15. {create_awesome_python_app-0.2.8 → create_awesome_python_app-0.2.10}/tests/test_catalog.py +0 -0
  16. {create_awesome_python_app-0.2.8 → create_awesome_python_app-0.2.10}/tests/test_catalog_fetch.py +0 -0
  17. {create_awesome_python_app-0.2.8 → create_awesome_python_app-0.2.10}/tests/test_catalog_resolve.py +0 -0
  18. {create_awesome_python_app-0.2.8 → create_awesome_python_app-0.2.10}/tests/test_cpa_templates_integration.py +0 -0
  19. {create_awesome_python_app-0.2.8 → create_awesome_python_app-0.2.10}/tests/test_flags.py +0 -0
  20. {create_awesome_python_app-0.2.8 → create_awesome_python_app-0.2.10}/tests/test_interactive.py +0 -0
  21. {create_awesome_python_app-0.2.8 → create_awesome_python_app-0.2.10}/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.8
3
+ Version: 0.2.10
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.6
11
+ Requires-Dist: create-python-app-core>=0.2.10
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.8"
3
+ version = "0.2.10"
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.6",
9
+ "create-python-app-core>=0.2.10",
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.8"
3
+ __version__ = "0.2.10"
@@ -12,6 +12,8 @@ import typer
12
12
  from create_python_app_core import (
13
13
  ConfigParseError,
14
14
  CpaCustomOption,
15
+ NonEmptyTargetDirectoryError,
16
+ assert_directory_is_empty,
15
17
  check_for_latest_version,
16
18
  check_python_version,
17
19
  create_python_app,
@@ -76,10 +78,15 @@ def _expand_variadic_option(argv: list[str], option: str) -> list[str]:
76
78
 
77
79
  Typer's ``list[str]`` Option only accepts one value per flag. Commander uses
78
80
  ``--addons [extensions...]``, so users naturally write space-separated lists.
81
+
82
+ When ``project_directory`` comes *after* options and no positional was seen
83
+ yet, peel the final trailing token at EOS back as the directory so that
84
+ ``--addons a --addons b /tmp/app`` does not treat ``/tmp/app`` as an addon.
79
85
  """
80
86
  out: list[str] = []
81
87
  i = 0
82
88
  prefix = option + "="
89
+ saw_positional = False
83
90
  while i < len(argv):
84
91
  arg = argv[i]
85
92
  if arg == option:
@@ -88,17 +95,26 @@ def _expand_variadic_option(argv: list[str], option: str) -> list[str]:
88
95
  while i < len(argv) and not argv[i].startswith("-"):
89
96
  values.append(argv[i])
90
97
  i += 1
98
+ ended_at_eos = i >= len(argv)
99
+ trailing: str | None = None
100
+ if ended_at_eos and not saw_positional and len(values) >= 2:
101
+ trailing = values.pop()
91
102
  if not values:
92
103
  out.append(option)
93
104
  else:
94
105
  for value in values:
95
106
  out.extend([option, value])
107
+ if trailing is not None:
108
+ out.append(trailing)
109
+ saw_positional = True
96
110
  continue
97
111
  if arg.startswith(prefix):
98
112
  value = arg[len(prefix) :]
99
113
  out.extend([option, value] if value else [option])
100
114
  i += 1
101
115
  continue
116
+ if i > 0 and not arg.startswith("-"):
117
+ saw_positional = True
102
118
  out.append(arg)
103
119
  i += 1
104
120
  return out
@@ -106,7 +122,9 @@ def _expand_variadic_option(argv: list[str], option: str) -> list[str]:
106
122
 
107
123
  def _preprocess_cli_argv(argv: list[str] | None = None) -> list[str]:
108
124
  """Apply argv rewrites needed before Typer parses the CLI."""
109
- out = _preprocess_fixture_argv(argv)
125
+ raw = list(sys.argv if argv is None else argv)
126
+ # Pass an explicit list so fixture preprocess does not mutate sys.argv early.
127
+ out = _preprocess_fixture_argv(raw)
110
128
  out = _expand_variadic_option(out, "--addons")
111
129
  out = _expand_variadic_option(out, "--extend")
112
130
  if argv is None:
@@ -307,6 +325,18 @@ def scaffold(
307
325
  la(template)
308
326
  raise typer.Exit(0)
309
327
 
328
+ target_directory = project_directory or "my-project"
329
+ # Fail before the interactive wizard so a leftover `my-project/` does not
330
+ # waste a full prompt session (and so we exit cleanly, not with a traceback).
331
+ if not force:
332
+ try:
333
+ assert_directory_is_empty(
334
+ Path(target_directory).expanduser().resolve(), force=False
335
+ )
336
+ except NonEmptyTargetDirectoryError as err:
337
+ console.print(f"[red]{err}[/red]")
338
+ raise typer.Exit(1) from err
339
+
310
340
  effective_refresh = _normalize_refresh(refresh)
311
341
  if refresh and effective_refresh is None:
312
342
  console.print(
@@ -524,25 +554,31 @@ def scaffold(
524
554
  raise typer.Exit(1)
525
555
  console.print(f"[yellow]{msg}[/yellow]")
526
556
 
527
- asyncio.run(
528
- create_python_app(
529
- project_directory or "my-project",
530
- {
531
- "template": template,
532
- "addons": addons or [],
533
- "extend": extend or [],
534
- "install": not no_install,
535
- "force": force,
536
- "verbose": verbose,
537
- "offline": offline,
538
- "refresh": effective_refresh,
539
- "keep_on_failure": keep_on_failure,
540
- "cache_dir": str(cache_dir) if cache_dir else None,
541
- "set": set_map,
542
- },
557
+ try:
558
+ asyncio.run(
559
+ create_python_app(
560
+ target_directory,
561
+ {
562
+ "template": template,
563
+ "addons": addons or [],
564
+ "extend": extend or [],
565
+ "install": not no_install,
566
+ "force": force,
567
+ "verbose": verbose,
568
+ "offline": offline,
569
+ "refresh": effective_refresh,
570
+ "keep_on_failure": keep_on_failure,
571
+ "cache_dir": str(cache_dir) if cache_dir else None,
572
+ "set": set_map,
573
+ },
574
+ )
543
575
  )
544
- )
545
- console.print(f"[green]Created[/green] {project_directory}")
576
+ except NonEmptyTargetDirectoryError as err:
577
+ # Safety net if the target fills up after the early check (e.g. during
578
+ # a long interactive session).
579
+ console.print(f"[red]{err}[/red]")
580
+ raise typer.Exit(1) from err
581
+ console.print(f"[green]Created[/green] {target_directory}")
546
582
 
547
583
 
548
584
  @cache_app.command("dir")
@@ -320,6 +320,59 @@ def test_expand_variadic_addons_and_extend() -> None:
320
320
  ]
321
321
 
322
322
 
323
+ def test_expand_preserves_trailing_project_directory() -> None:
324
+ """Repeated ``--addons`` with directory last must not swallow the path."""
325
+ from create_awesome_python_app.cli import _preprocess_cli_argv
326
+
327
+ assert _preprocess_cli_argv(
328
+ [
329
+ "cpa",
330
+ "--addons",
331
+ "github-setup",
332
+ "--addons",
333
+ "fastapi-docker",
334
+ "/tmp/app",
335
+ ]
336
+ ) == [
337
+ "cpa",
338
+ "--addons",
339
+ "github-setup",
340
+ "--addons",
341
+ "fastapi-docker",
342
+ "/tmp/app",
343
+ ]
344
+ # CI-shaped argv: flags between last addon and directory.
345
+ assert (
346
+ _preprocess_cli_argv(
347
+ [
348
+ "cpa",
349
+ "--template",
350
+ "fastapi-starter",
351
+ "--addons",
352
+ "fastapi-docker",
353
+ "--addons",
354
+ "github-setup",
355
+ "--no-interactive",
356
+ "--no-install",
357
+ "--force",
358
+ "/tmp/app",
359
+ ]
360
+ )[-1]
361
+ == "/tmp/app"
362
+ )
363
+ # Space-separated addons with directory last (no prior positional).
364
+ assert _preprocess_cli_argv(
365
+ ["cpa", "--addons", "fastapi-docker", "github-setup", "/tmp/app"]
366
+ ) == [
367
+ "cpa",
368
+ "--addons",
369
+ "fastapi-docker",
370
+ "--addons",
371
+ "github-setup",
372
+ "/tmp/app",
373
+ ]
374
+
375
+
323
376
  def test_space_separated_addons_after_project_directory(
324
377
  tmp_path: Path, monkeypatch
325
378
  ) -> None:
@@ -373,6 +426,57 @@ def test_space_separated_addons_after_project_directory(
373
426
  assert any("github-setup" in a for a in addons)
374
427
 
375
428
 
429
+ def test_repeated_addons_with_directory_last(tmp_path: Path, monkeypatch) -> None:
430
+ """``--addons a --addons b <dir>`` must keep ``<dir>`` as project_directory."""
431
+ from create_awesome_python_app.cli import _preprocess_cli_argv
432
+
433
+ tpl = tmp_path / "tpl"
434
+ tpl.mkdir()
435
+ target = tmp_path / "app"
436
+ captured: dict[str, object] = {}
437
+
438
+ async def fake_check_for_latest_version(_package_name):
439
+ return None
440
+
441
+ async def fake_create_python_app(project_directory, options, *_args, **_kwargs):
442
+ captured["project_directory"] = project_directory
443
+ captured["options"] = options
444
+
445
+ monkeypatch.setattr(
446
+ "create_awesome_python_app.cli.check_for_latest_version",
447
+ fake_check_for_latest_version,
448
+ )
449
+ monkeypatch.setattr(
450
+ "create_awesome_python_app.cli.create_python_app",
451
+ fake_create_python_app,
452
+ )
453
+
454
+ argv = _preprocess_cli_argv(
455
+ [
456
+ "cpa",
457
+ "--template",
458
+ f"file://{tpl}",
459
+ "--addons",
460
+ "fastapi-docker",
461
+ "--addons",
462
+ "github-setup",
463
+ "--no-install",
464
+ "--no-interactive",
465
+ str(target),
466
+ ]
467
+ )
468
+ result = runner.invoke(app, argv[1:])
469
+ text = (result.stdout or "") + (result.stderr or "")
470
+ assert result.exit_code == 0, text
471
+ assert captured["project_directory"] == str(target)
472
+ options = captured["options"]
473
+ assert isinstance(options, dict)
474
+ addons = options["addons"]
475
+ assert isinstance(addons, list)
476
+ assert len(addons) == 2
477
+ assert not any(str(target) in a for a in addons)
478
+
479
+
376
480
  def test_preprocess_fixture_argv_bare_and_with_dir() -> None:
377
481
  from create_awesome_python_app.cli import _FIXTURE_AUTO, _preprocess_fixture_argv
378
482
 
@@ -437,3 +541,91 @@ def test_list_templates_with_fixture_dir(
437
541
  assert result.exit_code == 0, text
438
542
  assert "fixture-only" in text
439
543
  assert os.environ.get("CPA_CATALOG_FIXTURE") == "1"
544
+
545
+
546
+ def test_non_empty_target_fails_before_scaffold(
547
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
548
+ ) -> None:
549
+ """Leftover target dir must fail immediately — not after interactive work."""
550
+ target = tmp_path / "existing"
551
+ target.mkdir()
552
+ (target / "leftover.txt").write_text("already here", encoding="utf-8")
553
+ tpl = tmp_path / "tpl"
554
+ tpl.mkdir()
555
+ called: dict[str, bool] = {"create": False}
556
+
557
+ async def fake_check_for_latest_version(_package_name):
558
+ return None
559
+
560
+ async def fake_create_python_app(*_args, **_kwargs):
561
+ called["create"] = True
562
+
563
+ monkeypatch.setattr(
564
+ "create_awesome_python_app.cli.check_for_latest_version",
565
+ fake_check_for_latest_version,
566
+ )
567
+ monkeypatch.setattr(
568
+ "create_awesome_python_app.cli.create_python_app",
569
+ fake_create_python_app,
570
+ )
571
+
572
+ result = runner.invoke(
573
+ app,
574
+ [
575
+ "--template",
576
+ f"file://{tpl}",
577
+ "--no-install",
578
+ "--no-interactive",
579
+ str(target),
580
+ ],
581
+ )
582
+ text = (result.stdout or "") + (result.stderr or "")
583
+ assert result.exit_code == 1, text
584
+ assert "not empty" in text.lower()
585
+ assert "--force" in text
586
+ assert called["create"] is False
587
+
588
+
589
+ def test_non_empty_target_allows_force(
590
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
591
+ ) -> None:
592
+ target = tmp_path / "existing"
593
+ target.mkdir()
594
+ (target / "leftover.txt").write_text("already here", encoding="utf-8")
595
+ tpl = tmp_path / "tpl"
596
+ tpl.mkdir()
597
+ captured: dict[str, object] = {}
598
+
599
+ async def fake_check_for_latest_version(_package_name):
600
+ return None
601
+
602
+ async def fake_create_python_app(project_directory, options, *_args, **_kwargs):
603
+ captured["project_directory"] = project_directory
604
+ captured["options"] = options
605
+
606
+ monkeypatch.setattr(
607
+ "create_awesome_python_app.cli.check_for_latest_version",
608
+ fake_check_for_latest_version,
609
+ )
610
+ monkeypatch.setattr(
611
+ "create_awesome_python_app.cli.create_python_app",
612
+ fake_create_python_app,
613
+ )
614
+
615
+ result = runner.invoke(
616
+ app,
617
+ [
618
+ "--template",
619
+ f"file://{tpl}",
620
+ "--force",
621
+ "--no-install",
622
+ "--no-interactive",
623
+ str(target),
624
+ ],
625
+ )
626
+ text = (result.stdout or "") + (result.stderr or "")
627
+ assert result.exit_code == 0, text
628
+ assert captured["project_directory"] == str(target)
629
+ options = captured["options"]
630
+ assert isinstance(options, dict)
631
+ assert options["force"] is True