From fe245e793cb006a083d6ea2eb06c4ec39b42d73a Mon Sep 17 00:00:00 2001 From: ulises-jeremias Date: Wed, 22 Jul 2026 11:44:54 -0300 Subject: [PATCH 1/2] 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 --- CHANGELOG.md | 7 ++ docs/TROUBLESHOOTING.md | 5 +- .../create-awesome-python-app/pyproject.toml | 4 +- .../src/create_awesome_python_app/__init__.py | 2 +- .../src/create_awesome_python_app/cli.py | 56 ++++++++---- .../tests/test_cli.py | 88 +++++++++++++++++++ .../create-python-app-core/pyproject.toml | 2 +- .../src/create_python_app_core/_version.py | 2 +- .../src/create_python_app_core/config.py | 5 +- .../tests/test_config.py | 2 +- uv.lock | 4 +- 11 files changed, 149 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d06c2b5..1844125 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 1932a06..ec6ba17 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -27,7 +27,10 @@ inherit `requires-python` from their template. `Target directory is not empty: `. **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:** diff --git a/packages/create-awesome-python-app/pyproject.toml b/packages/create-awesome-python-app/pyproject.toml index cbdadf3..78a5023 100644 --- a/packages/create-awesome-python-app/pyproject.toml +++ b/packages/create-awesome-python-app/pyproject.toml @@ -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", diff --git a/packages/create-awesome-python-app/src/create_awesome_python_app/__init__.py b/packages/create-awesome-python-app/src/create_awesome_python_app/__init__.py index 39ab44d..59c6258 100644 --- a/packages/create-awesome-python-app/src/create_awesome_python_app/__init__.py +++ b/packages/create-awesome-python-app/src/create_awesome_python_app/__init__.py @@ -1,3 +1,3 @@ """Create Awesome Python App CLI.""" -__version__ = "0.2.9" +__version__ = "0.2.10" diff --git a/packages/create-awesome-python-app/src/create_awesome_python_app/cli.py b/packages/create-awesome-python-app/src/create_awesome_python_app/cli.py index 3d92d33..a613fd2 100644 --- a/packages/create-awesome-python-app/src/create_awesome_python_app/cli.py +++ b/packages/create-awesome-python-app/src/create_awesome_python_app/cli.py @@ -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, @@ -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( @@ -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") diff --git a/packages/create-awesome-python-app/tests/test_cli.py b/packages/create-awesome-python-app/tests/test_cli.py index 19d6dd2..8504782 100644 --- a/packages/create-awesome-python-app/tests/test_cli.py +++ b/packages/create-awesome-python-app/tests/test_cli.py @@ -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 diff --git a/packages/create-python-app-core/pyproject.toml b/packages/create-python-app-core/pyproject.toml index 47ab7bf..5674fc3 100644 --- a/packages/create-python-app-core/pyproject.toml +++ b/packages/create-python-app-core/pyproject.toml @@ -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" diff --git a/packages/create-python-app-core/src/create_python_app_core/_version.py b/packages/create-python-app-core/src/create_python_app_core/_version.py index 01ef120..6232f7a 100644 --- a/packages/create-python-app-core/src/create_python_app_core/_version.py +++ b/packages/create-python-app-core/src/create_python_app_core/_version.py @@ -1 +1 @@ -__version__ = "0.2.6" +__version__ = "0.2.10" diff --git a/packages/create-python-app-core/src/create_python_app_core/config.py b/packages/create-python-app-core/src/create_python_app_core/config.py index c8ac898..332973c 100644 --- a/packages/create-python-app-core/src/create_python_app_core/config.py +++ b/packages/create-python-app-core/src/create_python_app_core/config.py @@ -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." + ) diff --git a/packages/create-python-app-core/tests/test_config.py b/packages/create-python-app-core/tests/test_config.py index a5567e6..ea6e27c 100644 --- a/packages/create-python-app-core/tests/test_config.py +++ b/packages/create-python-app-core/tests/test_config.py @@ -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) diff --git a/uv.lock b/uv.lock index 87bbf14..e575f71 100644 --- a/uv.lock +++ b/uv.lock @@ -138,7 +138,7 @@ wheels = [ [[package]] name = "create-awesome-python-app" -version = "0.2.9" +version = "0.2.10" source = { editable = "packages/create-awesome-python-app" } dependencies = [ { name = "create-python-app-core" }, @@ -157,7 +157,7 @@ requires-dist = [ [[package]] name = "create-python-app-core" -version = "0.2.6" +version = "0.2.10" source = { editable = "packages/create-python-app-core" } dependencies = [ { name = "httpx" }, From 9e4bb47b6319e68950935850726d7930762601fa Mon Sep 17 00:00:00 2001 From: ulises-jeremias Date: Wed, 22 Jul 2026 11:46:36 -0300 Subject: [PATCH 2/2] chore: re-trigger CI after ready-for-review Co-authored-by: Cursor