Skip to content

Commit fe245e7

Browse files
fix(cli): fail early on non-empty target dir (0.2.10)
Check emptiness before the interactive wizard and exit cleanly with a --force / rename hint instead of a post-prompt traceback. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent a531aad commit fe245e7

11 files changed

Lines changed: 149 additions & 28 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## 0.2.10 - 2026-07-22
4+
5+
### CLI / UX
6+
7+
- Validate non-empty target directory **before** the interactive wizard so a leftover default `my-project/` does not waste a full prompt session.
8+
- Exit cleanly with a hint to use `--force` or pick a different directory name (no traceback).
9+
310
## 0.2.9 - 2026-07-22
411

512
### CLI / argv trailing directory

docs/TROUBLESHOOTING.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ inherit `requires-python` from their template.
2727
`Target directory is not empty: <path>`.
2828

2929
**Cause:** CPA refuses to scaffold into a directory that already contains files,
30-
to avoid overwriting user data.
30+
to avoid overwriting user data. The default target is `my-project` when no
31+
directory argument is given — a leftover from a previous run is a common tripwire.
32+
Interactive mode checks this **before** the template/extension prompts so you
33+
do not lose a full wizard session to a traceback.
3134

3235
**Fix:**
3336

packages/create-awesome-python-app/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
[project]
22
name = "create-awesome-python-app"
3-
version = "0.2.9"
3+
version = "0.2.10"
44
description = "Composable scaffolding CLI for production-ready Python apps"
55
readme = "README.md"
66
requires-python = ">=3.12"
77
license = "MIT"
88
dependencies = [
9-
"create-python-app-core>=0.2.6",
9+
"create-python-app-core>=0.2.10",
1010
"questionary>=2.1.1",
1111
"rich>=15.0.0",
1212
"typer>=0.27.0",
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""Create Awesome Python App CLI."""
22

3-
__version__ = "0.2.9"
3+
__version__ = "0.2.10"

packages/create-awesome-python-app/src/create_awesome_python_app/cli.py

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
from create_python_app_core import (
1313
ConfigParseError,
1414
CpaCustomOption,
15+
NonEmptyTargetDirectoryError,
16+
assert_directory_is_empty,
1517
check_for_latest_version,
1618
check_python_version,
1719
create_python_app,
@@ -323,6 +325,18 @@ def scaffold(
323325
la(template)
324326
raise typer.Exit(0)
325327

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+
326340
effective_refresh = _normalize_refresh(refresh)
327341
if refresh and effective_refresh is None:
328342
console.print(
@@ -540,25 +554,31 @@ def scaffold(
540554
raise typer.Exit(1)
541555
console.print(f"[yellow]{msg}[/yellow]")
542556

543-
asyncio.run(
544-
create_python_app(
545-
project_directory or "my-project",
546-
{
547-
"template": template,
548-
"addons": addons or [],
549-
"extend": extend or [],
550-
"install": not no_install,
551-
"force": force,
552-
"verbose": verbose,
553-
"offline": offline,
554-
"refresh": effective_refresh,
555-
"keep_on_failure": keep_on_failure,
556-
"cache_dir": str(cache_dir) if cache_dir else None,
557-
"set": set_map,
558-
},
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+
)
559575
)
560-
)
561-
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}")
562582

563583

564584
@cache_app.command("dir")

packages/create-awesome-python-app/tests/test_cli.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,3 +541,91 @@ def test_list_templates_with_fixture_dir(
541541
assert result.exit_code == 0, text
542542
assert "fixture-only" in text
543543
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

packages/create-python-app-core/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "create-python-app-core"
3-
version = "0.2.6"
3+
version = "0.2.10"
44
description = "Scaffolding engine for Create Awesome Python App"
55
readme = "README.md"
66
requires-python = ">=3.12"
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.2.6"
1+
__version__ = "0.2.10"

packages/create-python-app-core/src/create_python_app_core/config.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,7 @@ def assert_directory_is_empty(path: Path, *, force: bool = False) -> None:
6060
if force:
6161
return
6262
if path.exists() and any(path.iterdir()):
63-
raise NonEmptyTargetDirectoryError(f"Target directory is not empty: {path}")
63+
raise NonEmptyTargetDirectoryError(
64+
f"Target directory is not empty: {path}. "
65+
"Use --force to continue, or pick a different directory name."
66+
)

packages/create-python-app-core/tests/test_config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,6 @@ def test_bad_json(tmp_path: Path) -> None:
3030

3131
def test_non_empty(tmp_path: Path) -> None:
3232
(tmp_path / "f").write_text("x")
33-
with pytest.raises(NonEmptyTargetDirectoryError):
33+
with pytest.raises(NonEmptyTargetDirectoryError, match="Use --force"):
3434
assert_directory_is_empty(tmp_path)
3535
assert_directory_is_empty(tmp_path, force=True)

0 commit comments

Comments
 (0)