From 62b12690c8cbf50ad52f388fa464e40d3e5e8189 Mon Sep 17 00:00:00 2001 From: Alexander Kalinovsky Date: Wed, 27 Aug 2025 16:37:37 +0300 Subject: [PATCH] chore: enhance project initialization by adding post-task handling and improving file overwrite behavior --- .gitignore | 1 + pyproject.toml | 2 +- src/quickbot_cli/cli.py | 104 +++++----- .../templates/basic/__template__.yaml | 10 +- tests/test_cli.py | 194 +++++++++++++----- tests/test_edge_cases.py | 111 ++++++---- tests/test_integration.py | 44 +++- 7 files changed, 308 insertions(+), 158 deletions(-) diff --git a/.gitignore b/.gitignore index fd71f39..b88e302 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ __pycache__ .mypy_cache uv.lock build/ +dist/ *.egg-info/ .coverage *.py,cover diff --git a/pyproject.toml b/pyproject.toml index b57914a..ba7b207 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ target-version = "py313" [tool.ruff.lint] select = ["ALL"] -ignore = ["D203", "D213", "COM812", "PLR0913"] +ignore = ["D203", "D213", "COM812", "PLR0913", "B008"] [tool.ruff.lint.per-file-ignores] "tests/**" = ["S101", "PT011"] diff --git a/src/quickbot_cli/cli.py b/src/quickbot_cli/cli.py index f1f59dd..bcf2277 100644 --- a/src/quickbot_cli/cli.py +++ b/src/quickbot_cli/cli.py @@ -20,7 +20,6 @@ TEMPLATES_DIR = Path(__file__).parent / "templates" # Module-level constants DEFAULT_TEMPLATE = "basic" BINARY_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".ico", ".pdf", ".zip"} -OUTPUT_DIR_ARG = typer.Argument(..., help="Output directory") def load_template_spec(template_dir: Path) -> dict[str, Any]: @@ -134,7 +133,8 @@ def ask_variables(spec: dict[str, Any], non_interactive: dict[str, Any]) -> dict for name, meta in vars_spec.items(): if name in non_interactive and non_interactive[name] is not None: - val: str | bool = str(non_interactive[name]) + # Preserve the original type (especially for booleans) + val = non_interactive[name] else: prompt = meta.get("prompt", name) default = meta.get("default") @@ -193,8 +193,9 @@ def render_tree( # Render template file output_file = output_dir / item.stem if output_file.exists() and not overwrite: - msg = f"File exists: {output_file}" - raise FileExistsError(msg) + # Skip existing file when overwrite is disabled + typer.secho(f"Warning: Skipping existing file: {output_file}", fg=typer.colors.YELLOW) + continue try: template = env.get_template(str(item.relative_to(root_for_path))) @@ -207,9 +208,10 @@ def render_tree( # Copy non-template file output_file = output_dir / item.name if output_file.exists() and not overwrite: - msg = f"File exists: {output_file}" - raise FileExistsError(msg) - shutil.copy2(item, output_file) + # Skip existing file when overwrite is disabled + typer.secho(f"Warning: Skipping existing file: {output_file}", fg=typer.colors.YELLOW) + continue + shutil.copy2(item, output_dir / item.name) elif item.is_dir(): # Recursively render subdirectory sub_output = output_dir / item.name @@ -244,43 +246,6 @@ def run_post_tasks(spec: dict[str, Any], context: dict[str, Any], cwd: Path) -> typer.secho(f"Post-task failed: {cmd} -> {e}", fg=typer.colors.RED) -def apply_optionals(output: Path, *, include_alembic: bool, include_i18n: bool) -> None: - """Apply optional module configurations by removing disabled modules. - - Args: - output: Output directory path - include_alembic: Whether to include Alembic - include_i18n: Whether to include i18n - - """ - # If module is disabled, remove its files - if not include_alembic: - for p in [ - output / "alembic", - output / "scripts" / "migrations_apply.sh", - output / "scripts" / "migrations_generate.sh", - ]: - if p.exists(): - if p.is_dir(): - shutil.rmtree(p) - else: - p.unlink(missing_ok=True) - - if not include_i18n: - for p in [ - output / "locales", - output / "scripts" / "babel_compile.sh", - output / "scripts" / "babel_extract.sh", - output / "scripts" / "babel_init.sh", - output / "scripts" / "babel_update.sh", - ]: - if p.exists(): - if p.is_dir(): - shutil.rmtree(p) - else: - p.unlink(missing_ok=True) - - def _init_project( output: Path, template: str = DEFAULT_TEMPLATE, @@ -292,6 +257,7 @@ def _init_project( include_alembic: bool | None = None, include_i18n: bool | None = None, overwrite: bool = False, + interactive: bool = False, ) -> None: """Generate a project with the structure app/ and optional Alembic / Babel.""" template_dir = TEMPLATES_DIR / template @@ -302,16 +268,33 @@ def _init_project( # Load template spec spec = load_template_spec(template_dir) - # Prepare context - context = { - "project_name": project_name or output.name, + # Prepare non-interactive values + non_interactive = { + "project_name": project_name, "description": description, "author": author, "license": license_name, - "include_alembic": bool(include_alembic) if include_alembic is not None else True, - "include_i18n": bool(include_i18n) if include_i18n is not None else True, + "include_alembic": include_alembic, + "include_i18n": include_i18n, } + # Resolve variables + if interactive: + context = ask_variables(spec, non_interactive) + else: + # Use provided values or spec defaults without prompting + context = {} + for name, meta in (spec.get("variables", {}) or {}).items(): + if name in non_interactive and non_interactive[name] is not None: + context[name] = non_interactive[name] + else: + context[name] = meta.get("default") + context["package_name"] = "app" + + # Ensure project_name is set (fallback to output directory name) + if not context.get("project_name"): + context["project_name"] = output.name + # Create output directory output.mkdir(parents=True, exist_ok=True) @@ -325,30 +308,28 @@ def _init_project( render_tree(env, template_dir, output, context, overwrite=overwrite, original_root=template_dir) - # Apply optional configurations - if include_alembic is not None or include_i18n is not None: - apply_optionals(output, include_alembic=bool(include_alembic), include_i18n=bool(include_i18n)) - # Run post-tasks run_post_tasks(spec, context, output) + # Optional modules are handled exclusively via post_tasks in template + typer.secho(f"Project generated successfully in {output}", fg=typer.colors.GREEN) @app.command() def init( - output: Path = OUTPUT_DIR_ARG, + output: Path = typer.Option(".", help="Output directory (defaults to current directory)"), template: str = typer.Option(DEFAULT_TEMPLATE, "--template", "-t"), project_name: str | None = typer.Option(None, help="Project name"), description: str | None = typer.Option(None, help="Description"), author: str | None = typer.Option(None, help="Author"), license_name: str | None = typer.Option(None, help="License"), *, - include_alembic: bool = typer.Option(default=True, help="Include Alembic"), - include_i18n: bool = typer.Option(default=True, help="Include i18n"), + include_alembic: bool | None = typer.Option(None, help="Include Alembic (will prompt if not specified)"), + include_i18n: bool | None = typer.Option(None, help="Include i18n (will prompt if not specified)"), overwrite: bool = typer.Option(default=False, help="Overwrite existing files"), ) -> None: - """CLI wrapper for _init_project function.""" + """Initialize a new project in the specified directory.""" _init_project( output=output, template=template, @@ -359,9 +340,18 @@ def init( include_alembic=include_alembic, include_i18n=include_i18n, overwrite=overwrite, + interactive=True, ) +@app.command() +def version() -> None: + """Show the version of quickbot-cli.""" + from quickbot_cli import __version__ # noqa: PLC0415 + + typer.echo(f"quickbot-cli version {__version__}") + + def main() -> None: """Run the main CLI application.""" app() # pragma: no cover diff --git a/src/quickbot_cli/templates/basic/__template__.yaml b/src/quickbot_cli/templates/basic/__template__.yaml index e380cca..bf6d43a 100644 --- a/src/quickbot_cli/templates/basic/__template__.yaml +++ b/src/quickbot_cli/templates/basic/__template__.yaml @@ -24,4 +24,12 @@ variables: include_i18n: prompt: "Include i18n?" choices: [true, false] - default: true \ No newline at end of file + default: true + +post_tasks: + - when: "{{ not include_alembic }}" + run: ["rm", "-rf", "alembic", "scripts/migrations_apply.sh", "scripts/migrations_generate.sh"] + - when: "{{ not include_i18n }}" + run: ["rm", "-rf", "locales", "scripts/babel_compile.sh", "scripts/babel_extract.sh", "scripts/babel_init.sh", "scripts/babel_update.sh"] + + \ No newline at end of file diff --git a/tests/test_cli.py b/tests/test_cli.py index e8e9b4e..4b7de22 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -21,7 +21,6 @@ from jinja2 import Environment, FileSystemLoader, StrictUndefined from quickbot_cli.cli import ( _init_project, app, - apply_optionals, ask_variables, init, load_template_spec, @@ -252,8 +251,8 @@ class TestRenderTree: assert output_file.exists() assert output_file.read_bytes() == binary_content - def test_render_tree_binary_file_exists_error(self, temp_dir: Path) -> None: - """Test that render_tree raises error when binary file exists and overwrite is disabled.""" + def test_render_tree_binary_file_existing_is_skipped(self, temp_dir: Path, mock_typer_secho: MagicMock) -> None: + """Existing binary file should be left untouched when overwrite is disabled.""" template_root = temp_dir / "template" template_root.mkdir() @@ -272,11 +271,18 @@ class TestRenderTree: context = {"project_name": "test_project"} env = Environment(loader=FileSystemLoader(str(template_root)), undefined=StrictUndefined, autoescape=True) - with pytest.raises(FileExistsError, match="File exists:"): - render_tree(env, template_root, output_dir, context, overwrite=False) + render_tree(env, template_root, output_dir, context, overwrite=False) + # Should remain the existing content + assert (output_dir / "image.png").read_bytes() == b"existing_binary_data" + # Should show warning + mock_typer_secho.assert_called_with( + f"Warning: Skipping existing file: {output_dir / 'image.png'}", fg=typer.colors.YELLOW + ) - def test_render_tree_with_overwrite_disabled(self, temp_dir: Path) -> None: - """Test that render_tree raises error when overwrite is disabled and file exists.""" + def test_render_tree_with_overwrite_disabled_skips_existing( + self, temp_dir: Path, mock_typer_secho: MagicMock + ) -> None: + """Existing text files should be skipped when overwrite is disabled.""" template_root = temp_dir / "template" template_root.mkdir() @@ -293,8 +299,13 @@ class TestRenderTree: context = {"project_name": "test_project"} env = Environment(loader=FileSystemLoader(str(template_root)), undefined=StrictUndefined, autoescape=True) - with pytest.raises(FileExistsError): - render_tree(env, template_root, output_dir, context, overwrite=False) + render_tree(env, template_root, output_dir, context, overwrite=False) + # File content should remain unchanged + assert (output_dir / "main.py").read_text() == "existing content" + # Should show warning + mock_typer_secho.assert_called_with( + f"Warning: Skipping existing file: {output_dir / 'main.py'}", fg=typer.colors.YELLOW + ) def test_render_tree_with_overwrite_enabled(self, temp_dir: Path) -> None: """Test that render_tree overwrites existing files when enabled.""" @@ -353,65 +364,77 @@ class TestRunPostTasks: # The actual error handling is tested in the main run_post_tasks function -class TestApplyOptionals: - """Test optional module inclusion/exclusion.""" - - def test_apply_optionals_disables_alembic(self, temp_dir: Path) -> None: - """Test that apply_optionals removes alembic files when disabled.""" - # Create alembic files - alembic_dir = temp_dir / "alembic" - alembic_dir.mkdir() - (alembic_dir / "alembic.ini").write_text("config") +class TestPostTasksApplyOptionalsBehavior: + """Optional module inclusion/exclusion is applied via post_tasks now.""" + def test_post_tasks_removes_alembic_when_disabled(self, temp_dir: Path) -> None: + """post_tasks should remove Alembic artifacts when disabled.""" + # Create structure + (temp_dir / "alembic").mkdir() scripts_dir = temp_dir / "scripts" scripts_dir.mkdir() (scripts_dir / "migrations_generate.sh").write_text("script") (scripts_dir / "migrations_apply.sh").write_text("script") - apply_optionals(temp_dir, include_alembic=False, include_i18n=True) + spec = { + "post_tasks": [ + { + "when": "{{ not include_alembic }}", + "run": [ + "rm", + "-rf", + "alembic", + "scripts/migrations_apply.sh", + "scripts/migrations_generate.sh", + ], + } + ] + } + context = {"include_alembic": False} + run_post_tasks(spec, context, temp_dir) - assert not alembic_dir.exists() + assert not (temp_dir / "alembic").exists() assert not (scripts_dir / "migrations_generate.sh").exists() assert not (scripts_dir / "migrations_apply.sh").exists() - def test_apply_optionals_disables_babel(self, temp_dir: Path) -> None: - """Test that apply_optionals removes babel files when disabled.""" - # Create babel files - locales_dir = temp_dir / "locales" - locales_dir.mkdir() - (locales_dir / "en").mkdir() - + def test_post_tasks_removes_i18n_when_disabled(self, temp_dir: Path) -> None: + """post_tasks should remove i18n artifacts when disabled.""" + (temp_dir / "locales").mkdir() scripts_dir = temp_dir / "scripts" scripts_dir.mkdir() - (scripts_dir / "babel_init.sh").write_text("script") - (scripts_dir / "babel_extract.sh").write_text("script") - (scripts_dir / "babel_update.sh").write_text("script") - (scripts_dir / "babel_compile.sh").write_text("script") + for f in [ + "babel_init.sh", + "babel_extract.sh", + "babel_update.sh", + "babel_compile.sh", + ]: + (scripts_dir / f).write_text("script") - apply_optionals(temp_dir, include_alembic=True, include_i18n=False) + spec = { + "post_tasks": [ + { + "when": "{{ not include_i18n }}", + "run": [ + "rm", + "-rf", + "locales", + "scripts/babel_compile.sh", + "scripts/babel_extract.sh", + "scripts/babel_init.sh", + "scripts/babel_update.sh", + ], + } + ] + } + context = {"include_i18n": False} + run_post_tasks(spec, context, temp_dir) - assert not locales_dir.exists() + assert not (temp_dir / "locales").exists() assert not (scripts_dir / "babel_init.sh").exists() assert not (scripts_dir / "babel_extract.sh").exists() assert not (scripts_dir / "babel_update.sh").exists() assert not (scripts_dir / "babel_compile.sh").exists() - def test_apply_optionals_keeps_enabled_modules(self, temp_dir: Path) -> None: - """Test that apply_optionals keeps files for enabled modules.""" - # Create both module files - alembic_dir = temp_dir / "alembic" - alembic_dir.mkdir() - (alembic_dir / "alembic.ini").write_text("config") - - locales_dir = temp_dir / "locales" - locales_dir.mkdir() - (locales_dir / "en").mkdir() - - apply_optionals(temp_dir, include_alembic=True, include_i18n=True) - - assert alembic_dir.exists() - assert locales_dir.exists() - class TestInitCommand: """Test the main init command.""" @@ -510,15 +533,70 @@ class TestInitCommand: output_path = temp_dir / "output" _init_project(output_path, "basic", overwrite=True) + def test_init_into_existing_dir_with_pyproject_is_ok_when_not_overwriting(self, temp_dir: Path) -> None: + """Regression: initializing into dir with existing pyproject.toml should skip it and proceed.""" + with patch("quickbot_cli.cli.TEMPLATES_DIR", temp_dir / "templates"): + template_dir = temp_dir / "templates" / "basic" + template_dir.mkdir(parents=True) + + # Spec and minimal files including pyproject template + (template_dir / "__template__.yaml").write_text( + "variables:\n project_name:\n prompt: Project name\n default: test_project" + ) + (template_dir / "app").mkdir() + (template_dir / "app" / "main.py.j2").write_text("ok") + (template_dir / "pyproject.toml.j2").write_text("[project]\nname='{{ project_name }}'") + + # Prepare existing output with pyproject.toml + output_path = temp_dir / "output" + output_path.mkdir() + (output_path / "pyproject.toml").write_text("[project]\nname='existing'") + + # Should not raise, and should keep existing pyproject.toml + _init_project(output_path, "basic", overwrite=False) + assert (output_path / "pyproject.toml").read_text() == "[project]\nname='existing'" + # New files should be generated + assert (output_path / "app" / "main.py").exists() + def test_cli_boolean_flags_defaults_and_negation(self, temp_dir: Path) -> None: """init() should honor boolean defaults and negation when called directly.""" with patch("quickbot_cli.cli.TEMPLATES_DIR", temp_dir / "templates"): template_dir = temp_dir / "templates" / "basic" template_dir.mkdir(parents=True) - # Minimal spec and files + # Spec with variables and post_tasks to remove disabled modules (template_dir / "__template__.yaml").write_text( - "variables:\n project_name:\n prompt: P\n default: test_project\n" + """ +variables: + project_name: + prompt: P + default: test_project + include_alembic: + prompt: A + choices: [true, false] + default: true + include_i18n: + prompt: I + choices: [true, false] + default: true +post_tasks: + - when: "{{ not include_alembic }}" + run: [ + "rm","-rf", + "alembic", + "scripts/migrations_apply.sh", + "scripts/migrations_generate.sh" + ] + - when: "{{ not include_i18n }}" + run: [ + "rm","-rf", + "locales", + "scripts/babel_compile.sh", + "scripts/babel_extract.sh", + "scripts/babel_init.sh", + "scripts/babel_update.sh" + ] +""" ) (template_dir / "app").mkdir() (template_dir / "app" / "main.py.j2").write_text("ok") @@ -528,6 +606,11 @@ class TestInitCommand: (template_dir / "locales" / "en").mkdir(parents=True, exist_ok=True) (template_dir / "scripts").mkdir() (template_dir / "scripts" / "babel_init.sh.j2").write_text("b") + (template_dir / "scripts" / "babel_extract.sh.j2").write_text("b") + (template_dir / "scripts" / "babel_update.sh.j2").write_text("b") + (template_dir / "scripts" / "babel_compile.sh.j2").write_text("b") + (template_dir / "scripts" / "migrations_apply.sh.j2").write_text("b") + (template_dir / "scripts" / "migrations_generate.sh.j2").write_text("b") # Default (both enabled) out1 = temp_dir / "out1" @@ -557,7 +640,8 @@ class TestCLIHelp: result = cli_runner.invoke(app, ["--help"]) assert result.exit_code == 0 # Check for the actual help text that appears - assert "init [OPTIONS] OUTPUT" in result.output + assert "init" in result.output + assert "version" in result.output def test_init_command_help(self, cli_runner: CliRunner) -> None: """Test that init command shows help information.""" @@ -565,15 +649,15 @@ class TestCLIHelp: result = cli_runner.invoke(app, ["init", "--help"]) assert result.exit_code == 0 # Check for the actual help text that appears - assert "OUTPUT" in result.output - assert "PATH" in result.output + assert "--output" in result.output + assert "Output directory" in result.output def test_init_command_arguments(self, cli_runner: CliRunner) -> None: """Test that init command accepts required arguments.""" # Test the actual CLI interface result = cli_runner.invoke(app, ["init", "--help"]) assert result.exit_code == 0 - assert "OUTPUT" in result.output + assert "--output" in result.output def test_cli_wrapper_function(self) -> None: """Test that the CLI wrapper function exists and is callable.""" diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py index ba21b86..ac6782c 100644 --- a/tests/test_edge_cases.py +++ b/tests/test_edge_cases.py @@ -20,7 +20,6 @@ import jinja2 from jinja2 import Environment, FileSystemLoader, StrictUndefined from quickbot_cli.cli import ( - apply_optionals, ask_variables, load_template_spec, render_tree, @@ -237,38 +236,71 @@ class TestEdgeCases: mock_subprocess_run.assert_not_called() - def test_apply_optionals_with_missing_directories(self, temp_dir: Path) -> None: - """Test apply_optionals with missing directories.""" - # Don't create any directories, just test the function - apply_optionals(temp_dir, include_alembic=False, include_i18n=False) - - # Should not raise any errors + def test_post_tasks_with_missing_directories(self, temp_dir: Path) -> None: + """Post tasks should tolerate missing directories and files.""" + spec = { + "post_tasks": [ + { + "when": "{{ not include_alembic }}", + "run": [ + "rm", + "-rf", + "alembic", + "scripts/migrations_apply.sh", + "scripts/migrations_generate.sh", + ], + }, + { + "when": "{{ not include_i18n }}", + "run": [ + "rm", + "-rf", + "locales", + "scripts/babel_compile.sh", + "scripts/babel_extract.sh", + "scripts/babel_init.sh", + "scripts/babel_update.sh", + ], + }, + ] + } + run_post_tasks(spec, {"include_alembic": False, "include_i18n": False}, temp_dir) assert True - def test_apply_optionals_with_partial_structure(self, temp_dir: Path) -> None: - """Test apply_optionals with partial directory structure.""" - # Create only some of the expected directories + def test_post_tasks_with_partial_structure(self, temp_dir: Path) -> None: + """Post tasks should handle partial directory structure.""" (temp_dir / "alembic").mkdir() (temp_dir / "scripts").mkdir() (temp_dir / "scripts" / "migrations_generate.sh").write_text("script") - # Don't create locales or babel scripts - - apply_optionals(temp_dir, include_alembic=False, include_i18n=True) - - # Alembic should be removed + spec = { + "post_tasks": [ + { + "when": "{{ not include_alembic }}", + "run": [ + "rm", + "-rf", + "alembic", + "scripts/migrations_generate.sh", + ], + } + ] + } + run_post_tasks(spec, {"include_alembic": False}, temp_dir) assert not (temp_dir / "alembic").exists() assert not (temp_dir / "scripts" / "migrations_generate.sh").exists() - def test_apply_optionals_with_files_instead_of_directories(self, temp_dir: Path) -> None: - """Test apply_optionals with files instead of directories.""" - # Create files with names that match expected directories + def test_post_tasks_with_files_instead_of_directories(self, temp_dir: Path) -> None: + """Post tasks should remove files named like directories as well.""" (temp_dir / "alembic").write_text("not a directory") (temp_dir / "locales").write_text("not a directory") - - apply_optionals(temp_dir, include_alembic=False, include_i18n=False) - - # Files should be removed + spec = { + "post_tasks": [ + {"when": "{{ not include_alembic }}", "run": ["rm", "-rf", "alembic"]}, + {"when": "{{ not include_i18n }}", "run": ["rm", "-rf", "locales"]}, + ] + } + run_post_tasks(spec, {"include_alembic": False, "include_i18n": False}, temp_dir) assert not (temp_dir / "alembic").exists() assert not (temp_dir / "locales").exists() @@ -302,12 +334,14 @@ class TestErrorHandling: with pytest.raises((SystemExit, Exception)): ask_variables(spec, non_interactive) - def test_render_tree_with_file_exists_error(self, temp_dir: Path) -> None: - """Test that render_tree raises FileExistsError when overwrite is disabled.""" + def test_render_tree_skips_existing_when_overwrite_disabled( + self, temp_dir: Path, mock_typer_secho: MagicMock + ) -> None: + """Existing files are skipped (not overwritten) when overwrite is disabled.""" template_root = temp_dir / "template" template_root.mkdir() - template_file = template_root / "main.py.j2" + template_file = template_root / "main.py" template_file.write_text("app = FastAPI(title='{{ project_name }}')") output_dir = temp_dir / "output" @@ -320,8 +354,13 @@ class TestErrorHandling: context: dict[str, Any] = {"project_name": "test"} env = Environment(autoescape=True) - with pytest.raises(FileExistsError): - render_tree(env, template_root, output_dir, context, overwrite=False) + render_tree(env, template_root, output_dir, context, overwrite=False) + # Ensure original content remains + assert (output_dir / "main.py").read_text() == "existing content" + # Should show warning + mock_typer_secho.assert_called_with( + f"Warning: Skipping existing file: {output_dir / 'main.py'}", fg=typer.colors.YELLOW + ) def test_render_tree_with_jinja_render_error(self, temp_dir: Path, mock_typer_secho: MagicMock) -> None: """Test that render_tree logs and re-raises when Jinja rendering fails.""" @@ -418,23 +457,23 @@ class TestBoundaryConditions: ) assert deep_file.exists() - def test_apply_optionals_with_many_files(self, temp_dir: Path) -> None: - """Test apply_optionals with many files to process.""" - # Create many files + def test_post_tasks_with_many_files(self, temp_dir: Path) -> None: + """Post tasks should remove only specific optional module directories.""" for i in range(100): (temp_dir / f"file_{i}.txt").write_text(f"content {i}") - # Create the expected structure (temp_dir / "alembic").mkdir() (temp_dir / "locales").mkdir() - # Should not raise any errors - apply_optionals(temp_dir, include_alembic=False, include_i18n=False) - - # Only the specific optional modules should be removed, not the random files + spec = { + "post_tasks": [ + {"when": "{{ not include_alembic }}", "run": ["rm", "-rf", "alembic"]}, + {"when": "{{ not include_i18n }}", "run": ["rm", "-rf", "locales"]}, + ] + } + run_post_tasks(spec, {"include_alembic": False, "include_i18n": False}, temp_dir) assert not (temp_dir / "alembic").exists() assert not (temp_dir / "locales").exists() - # Random files should still exist assert (temp_dir / "file_0.txt").exists() assert (temp_dir / "file_50.txt").exists() diff --git a/tests/test_integration.py b/tests/test_integration.py index 12df797..74e8a18 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -10,7 +10,8 @@ from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch -import pytest +# pytest is used by the test framework +import typer from quickbot_cli.cli import _init_project @@ -203,9 +204,33 @@ class TestCLIIntegration: spec_content = { "variables": { "project_name": {"prompt": "Project name", "default": "simple_bot"}, - "include_alembic": {"prompt": "Include Alembic?", "choices": ["yes", "no"], "default": "no"}, - "include_i18n": {"prompt": "Include i18n?", "choices": ["yes", "no"], "default": "no"}, - } + "include_alembic": {"prompt": "Include Alembic?", "choices": [True, False], "default": False}, + "include_i18n": {"prompt": "Include i18n?", "choices": [True, False], "default": False}, + }, + "post_tasks": [ + { + "when": "{{ not include_alembic }}", + "run": [ + "rm", + "-rf", + "alembic", + "scripts/migrations_generate.sh", + "scripts/migrations_apply.sh", + ], + }, + { + "when": "{{ not include_i18n }}", + "run": [ + "rm", + "-rf", + "locales", + "scripts/babel_init.sh", + "scripts/babel_extract.sh", + "scripts/babel_update.sh", + "scripts/babel_compile.sh", + ], + }, + ], } spec_file = template_dir / "__template__.yaml" @@ -283,8 +308,8 @@ class TestCLIIntegration: assert "app = FastAPI(title='overwrite_test')" in (output_dir / "app" / "main.py").read_text() assert "existing content" not in (output_dir / "app" / "main.py").read_text() - def test_project_generation_without_overwrite_fails(self) -> None: - """Test that project generation fails without overwrite when files exist.""" + def test_project_generation_without_overwrite_skips_existing(self) -> None: + """Project generation should skip existing files when overwrite is False.""" with tempfile.TemporaryDirectory() as tmp_dir: tmp_path = Path(tmp_dir) @@ -310,9 +335,12 @@ class TestCLIIntegration: existing_file.parent.mkdir(parents=True, exist_ok=True) existing_file.write_text("existing content") - # Should fail with FileExistsError when overwrite is False - with pytest.raises(FileExistsError): + # Should complete and keep the existing file content + with patch("quickbot_cli.cli.typer.secho") as mock_secho: _init_project(output_dir, "basic", project_name="overwrite_test") + # Should show warning about skipping existing file + expected_warning = f"Warning: Skipping existing file: {output_dir / 'app' / 'main.py'}" + mock_secho.assert_any_call(expected_warning, fg=typer.colors.YELLOW) # Check that file was not overwritten assert "existing content" in (output_dir / "app" / "main.py").read_text()