Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
# Changelog

## 0.2.10 - 2026-07-22

### CLI / UX

- Validate non-empty target directory **before** the interactive wizard so a leftover default `my-project/` does not waste a full prompt session.
- Exit cleanly with a hint to use `--force` or pick a different directory name (no traceback).

## 0.2.9 - 2026-07-22

### CLI / argv trailing directory
Expand Down
5 changes: 4 additions & 1 deletion docs/TROUBLESHOOTING.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,10 @@ inherit `requires-python` from their template.
`Target directory is not empty: <path>`.

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

**Fix:**

Expand Down
4 changes: 2 additions & 2 deletions packages/create-awesome-python-app/pyproject.toml
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
[project]
name = "create-awesome-python-app"
version = "0.2.9"
version = "0.2.10"
description = "Composable scaffolding CLI for production-ready Python apps"
readme = "README.md"
requires-python = ">=3.12"
license = "MIT"
dependencies = [
"create-python-app-core>=0.2.6",
"create-python-app-core>=0.2.10",
"questionary>=2.1.1",
"rich>=15.0.0",
"typer>=0.27.0",
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
"""Create Awesome Python App CLI."""

__version__ = "0.2.9"
__version__ = "0.2.10"
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,8 @@
from create_python_app_core import (
ConfigParseError,
CpaCustomOption,
NonEmptyTargetDirectoryError,
assert_directory_is_empty,
check_for_latest_version,
check_python_version,
create_python_app,
Expand DownExpand Up@@ -323,6 +325,18 @@ def scaffold(
la(template)
raise typer.Exit(0)

target_directory = project_directory or "my-project"
# Fail before the interactive wizard so a leftover `my-project/` does not
# waste a full prompt session (and so we exit cleanly, not with a traceback).
if not force:
try:
assert_directory_is_empty(
Path(target_directory).expanduser().resolve(), force=False
)
except NonEmptyTargetDirectoryError as err:
console.print(f"[red]{err}[/red]")
raise typer.Exit(1) from err

effective_refresh = _normalize_refresh(refresh)
if refresh and effective_refresh is None:
console.print(
Expand DownExpand Up@@ -540,25 +554,31 @@ def scaffold(
raise typer.Exit(1)
console.print(f"[yellow]{msg}[/yellow]")

asyncio.run(
create_python_app(
project_directory or "my-project",
{
"template": template,
"addons": addons or [],
"extend": extend or [],
"install": not no_install,
"force": force,
"verbose": verbose,
"offline": offline,
"refresh": effective_refresh,
"keep_on_failure": keep_on_failure,
"cache_dir": str(cache_dir) if cache_dir else None,
"set": set_map,
},
try:
asyncio.run(
create_python_app(
target_directory,
{
"template": template,
"addons": addons or [],
"extend": extend or [],
"install": not no_install,
"force": force,
"verbose": verbose,
"offline": offline,
"refresh": effective_refresh,
"keep_on_failure": keep_on_failure,
"cache_dir": str(cache_dir) if cache_dir else None,
"set": set_map,
},
)
)
)
console.print(f"[green]Created[/green] {project_directory}")
except NonEmptyTargetDirectoryError as err:
# Safety net if the target fills up after the early check (e.g. during
# a long interactive session).
console.print(f"[red]{err}[/red]")
raise typer.Exit(1) from err
console.print(f"[green]Created[/green] {target_directory}")


@cache_app.command("dir")
Expand Down
88 changes: 88 additions & 0 deletions packages/create-awesome-python-app/tests/test_cli.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -541,3 +541,91 @@ def test_list_templates_with_fixture_dir(
assert result.exit_code == 0, text
assert "fixture-only" in text
assert os.environ.get("CPA_CATALOG_FIXTURE") == "1"


def test_non_empty_target_fails_before_scaffold(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Leftover target dir must fail immediately — not after interactive work."""
target = tmp_path / "existing"
target.mkdir()
(target / "leftover.txt").write_text("already here", encoding="utf-8")
tpl = tmp_path / "tpl"
tpl.mkdir()
called: dict[str, bool] = {"create": False}

async def fake_check_for_latest_version(_package_name):
return None

async def fake_create_python_app(*_args, **_kwargs):
called["create"] = True

monkeypatch.setattr(
"create_awesome_python_app.cli.check_for_latest_version",
fake_check_for_latest_version,
)
monkeypatch.setattr(
"create_awesome_python_app.cli.create_python_app",
fake_create_python_app,
)

result = runner.invoke(
app,
[
"--template",
f"file://{tpl}",
"--no-install",
"--no-interactive",
str(target),
],
)
text = (result.stdout or "") + (result.stderr or "")
assert result.exit_code == 1, text
assert "not empty" in text.lower()
assert "--force" in text
assert called["create"] is False


def test_non_empty_target_allows_force(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
target = tmp_path / "existing"
target.mkdir()
(target / "leftover.txt").write_text("already here", encoding="utf-8")
tpl = tmp_path / "tpl"
tpl.mkdir()
captured: dict[str, object] = {}

async def fake_check_for_latest_version(_package_name):
return None

async def fake_create_python_app(project_directory, options, *_args, **_kwargs):
captured["project_directory"] = project_directory
captured["options"] = options

monkeypatch.setattr(
"create_awesome_python_app.cli.check_for_latest_version",
fake_check_for_latest_version,
)
monkeypatch.setattr(
"create_awesome_python_app.cli.create_python_app",
fake_create_python_app,
)

result = runner.invoke(
app,
[
"--template",
f"file://{tpl}",
"--force",
"--no-install",
"--no-interactive",
str(target),
],
)
text = (result.stdout or "") + (result.stderr or "")
assert result.exit_code == 0, text
assert captured["project_directory"] == str(target)
options = captured["options"]
assert isinstance(options, dict)
assert options["force"] is True
2 changes: 1 addition & 1 deletion packages/create-python-app-core/pyproject.toml
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
[project]
name = "create-python-app-core"
version = "0.2.6"
version = "0.2.10"
description = "Scaffolding engine for Create Awesome Python App"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
__version__ = "0.2.6"
__version__ = "0.2.10"
Original file line numberDiff line numberDiff line change
Expand Up@@ -60,4 +60,7 @@ def assert_directory_is_empty(path: Path, *, force: bool = False) -> None:
if force:
return
if path.exists() and any(path.iterdir()):
raise NonEmptyTargetDirectoryError(f"Target directory is not empty: {path}")
raise NonEmptyTargetDirectoryError(
f"Target directory is not empty: {path}. "
"Use --force to continue, or pick a different directory name."
)
2 changes: 1 addition & 1 deletion packages/create-python-app-core/tests/test_config.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -30,6 +30,6 @@ def test_bad_json(tmp_path: Path) -> None:

def test_non_empty(tmp_path: Path) -> None:
(tmp_path / "f").write_text("x")
with pytest.raises(NonEmptyTargetDirectoryError):
with pytest.raises(NonEmptyTargetDirectoryError, match="Use --force"):
assert_directory_is_empty(tmp_path)
assert_directory_is_empty(tmp_path, force=True)
4 changes: 2 additions & 2 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading