chore: enhance project initialization by adding post-task handling and improving file overwrite behavior
Some checks failed
CI / test (3.13) (push) Failing after 31s

This commit is contained in:
Alexander Kalinovsky
2025-08-27 16:37:37 +03:00
parent 90b0202f39
commit 62b12690c8
7 changed files with 308 additions and 158 deletions

1
.gitignore vendored
View File

@@ -11,6 +11,7 @@ __pycache__
.mypy_cache .mypy_cache
uv.lock uv.lock
build/ build/
dist/
*.egg-info/ *.egg-info/
.coverage .coverage
*.py,cover *.py,cover

View File

@@ -47,7 +47,7 @@ target-version = "py313"
[tool.ruff.lint] [tool.ruff.lint]
select = ["ALL"] select = ["ALL"]
ignore = ["D203", "D213", "COM812", "PLR0913"] ignore = ["D203", "D213", "COM812", "PLR0913", "B008"]
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"tests/**" = ["S101", "PT011"] "tests/**" = ["S101", "PT011"]

View File

@@ -20,7 +20,6 @@ TEMPLATES_DIR = Path(__file__).parent / "templates"
# Module-level constants # Module-level constants
DEFAULT_TEMPLATE = "basic" DEFAULT_TEMPLATE = "basic"
BINARY_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".ico", ".pdf", ".zip"} 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]: 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(): for name, meta in vars_spec.items():
if name in non_interactive and non_interactive[name] is not None: 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: else:
prompt = meta.get("prompt", name) prompt = meta.get("prompt", name)
default = meta.get("default") default = meta.get("default")
@@ -193,8 +193,9 @@ def render_tree(
# Render template file # Render template file
output_file = output_dir / item.stem output_file = output_dir / item.stem
if output_file.exists() and not overwrite: if output_file.exists() and not overwrite:
msg = f"File exists: {output_file}" # Skip existing file when overwrite is disabled
raise FileExistsError(msg) typer.secho(f"Warning: Skipping existing file: {output_file}", fg=typer.colors.YELLOW)
continue
try: try:
template = env.get_template(str(item.relative_to(root_for_path))) template = env.get_template(str(item.relative_to(root_for_path)))
@@ -207,9 +208,10 @@ def render_tree(
# Copy non-template file # Copy non-template file
output_file = output_dir / item.name output_file = output_dir / item.name
if output_file.exists() and not overwrite: if output_file.exists() and not overwrite:
msg = f"File exists: {output_file}" # Skip existing file when overwrite is disabled
raise FileExistsError(msg) typer.secho(f"Warning: Skipping existing file: {output_file}", fg=typer.colors.YELLOW)
shutil.copy2(item, output_file) continue
shutil.copy2(item, output_dir / item.name)
elif item.is_dir(): elif item.is_dir():
# Recursively render subdirectory # Recursively render subdirectory
sub_output = output_dir / item.name 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) 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( def _init_project(
output: Path, output: Path,
template: str = DEFAULT_TEMPLATE, template: str = DEFAULT_TEMPLATE,
@@ -292,6 +257,7 @@ def _init_project(
include_alembic: bool | None = None, include_alembic: bool | None = None,
include_i18n: bool | None = None, include_i18n: bool | None = None,
overwrite: bool = False, overwrite: bool = False,
interactive: bool = False,
) -> None: ) -> None:
"""Generate a project with the structure app/ and optional Alembic / Babel.""" """Generate a project with the structure app/ and optional Alembic / Babel."""
template_dir = TEMPLATES_DIR / template template_dir = TEMPLATES_DIR / template
@@ -302,16 +268,33 @@ def _init_project(
# Load template spec # Load template spec
spec = load_template_spec(template_dir) spec = load_template_spec(template_dir)
# Prepare context # Prepare non-interactive values
context = { non_interactive = {
"project_name": project_name or output.name, "project_name": project_name,
"description": description, "description": description,
"author": author, "author": author,
"license": license_name, "license": license_name,
"include_alembic": bool(include_alembic) if include_alembic is not None else True, "include_alembic": include_alembic,
"include_i18n": bool(include_i18n) if include_i18n is not None else True, "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 # Create output directory
output.mkdir(parents=True, exist_ok=True) 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) 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
run_post_tasks(spec, context, output) 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) typer.secho(f"Project generated successfully in {output}", fg=typer.colors.GREEN)
@app.command() @app.command()
def init( 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"), template: str = typer.Option(DEFAULT_TEMPLATE, "--template", "-t"),
project_name: str | None = typer.Option(None, help="Project name"), project_name: str | None = typer.Option(None, help="Project name"),
description: str | None = typer.Option(None, help="Description"), description: str | None = typer.Option(None, help="Description"),
author: str | None = typer.Option(None, help="Author"), author: str | None = typer.Option(None, help="Author"),
license_name: str | None = typer.Option(None, help="License"), license_name: str | None = typer.Option(None, help="License"),
*, *,
include_alembic: bool = typer.Option(default=True, help="Include Alembic"), include_alembic: bool | None = typer.Option(None, help="Include Alembic (will prompt if not specified)"),
include_i18n: bool = typer.Option(default=True, help="Include i18n"), 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"), overwrite: bool = typer.Option(default=False, help="Overwrite existing files"),
) -> None: ) -> None:
"""CLI wrapper for _init_project function.""" """Initialize a new project in the specified directory."""
_init_project( _init_project(
output=output, output=output,
template=template, template=template,
@@ -359,9 +340,18 @@ def init(
include_alembic=include_alembic, include_alembic=include_alembic,
include_i18n=include_i18n, include_i18n=include_i18n,
overwrite=overwrite, 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: def main() -> None:
"""Run the main CLI application.""" """Run the main CLI application."""
app() # pragma: no cover app() # pragma: no cover

View File

@@ -25,3 +25,11 @@ variables:
prompt: "Include i18n?" prompt: "Include i18n?"
choices: [true, false] choices: [true, false]
default: true 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"]

View File

@@ -21,7 +21,6 @@ from jinja2 import Environment, FileSystemLoader, StrictUndefined
from quickbot_cli.cli import ( from quickbot_cli.cli import (
_init_project, _init_project,
app, app,
apply_optionals,
ask_variables, ask_variables,
init, init,
load_template_spec, load_template_spec,
@@ -252,8 +251,8 @@ class TestRenderTree:
assert output_file.exists() assert output_file.exists()
assert output_file.read_bytes() == binary_content assert output_file.read_bytes() == binary_content
def test_render_tree_binary_file_exists_error(self, temp_dir: Path) -> None: def test_render_tree_binary_file_existing_is_skipped(self, temp_dir: Path, mock_typer_secho: MagicMock) -> None:
"""Test that render_tree raises error when binary file exists and overwrite is disabled.""" """Existing binary file should be left untouched when overwrite is disabled."""
template_root = temp_dir / "template" template_root = temp_dir / "template"
template_root.mkdir() template_root.mkdir()
@@ -272,11 +271,18 @@ class TestRenderTree:
context = {"project_name": "test_project"} context = {"project_name": "test_project"}
env = Environment(loader=FileSystemLoader(str(template_root)), undefined=StrictUndefined, autoescape=True) 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: def test_render_tree_with_overwrite_disabled_skips_existing(
"""Test that render_tree raises error when overwrite is disabled and file exists.""" 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 = temp_dir / "template"
template_root.mkdir() template_root.mkdir()
@@ -293,8 +299,13 @@ class TestRenderTree:
context = {"project_name": "test_project"} context = {"project_name": "test_project"}
env = Environment(loader=FileSystemLoader(str(template_root)), undefined=StrictUndefined, autoescape=True) 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: def test_render_tree_with_overwrite_enabled(self, temp_dir: Path) -> None:
"""Test that render_tree overwrites existing files when enabled.""" """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 # The actual error handling is tested in the main run_post_tasks function
class TestApplyOptionals: class TestPostTasksApplyOptionalsBehavior:
"""Test optional module inclusion/exclusion.""" """Optional module inclusion/exclusion is applied via post_tasks now."""
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")
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 = temp_dir / "scripts"
scripts_dir.mkdir() scripts_dir.mkdir()
(scripts_dir / "migrations_generate.sh").write_text("script") (scripts_dir / "migrations_generate.sh").write_text("script")
(scripts_dir / "migrations_apply.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_generate.sh").exists()
assert not (scripts_dir / "migrations_apply.sh").exists() assert not (scripts_dir / "migrations_apply.sh").exists()
def test_apply_optionals_disables_babel(self, temp_dir: Path) -> None: def test_post_tasks_removes_i18n_when_disabled(self, temp_dir: Path) -> None:
"""Test that apply_optionals removes babel files when disabled.""" """post_tasks should remove i18n artifacts when disabled."""
# Create babel files (temp_dir / "locales").mkdir()
locales_dir = temp_dir / "locales"
locales_dir.mkdir()
(locales_dir / "en").mkdir()
scripts_dir = temp_dir / "scripts" scripts_dir = temp_dir / "scripts"
scripts_dir.mkdir() scripts_dir.mkdir()
(scripts_dir / "babel_init.sh").write_text("script") for f in [
(scripts_dir / "babel_extract.sh").write_text("script") "babel_init.sh",
(scripts_dir / "babel_update.sh").write_text("script") "babel_extract.sh",
(scripts_dir / "babel_compile.sh").write_text("script") "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_init.sh").exists()
assert not (scripts_dir / "babel_extract.sh").exists() assert not (scripts_dir / "babel_extract.sh").exists()
assert not (scripts_dir / "babel_update.sh").exists() assert not (scripts_dir / "babel_update.sh").exists()
assert not (scripts_dir / "babel_compile.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: class TestInitCommand:
"""Test the main init command.""" """Test the main init command."""
@@ -510,15 +533,70 @@ class TestInitCommand:
output_path = temp_dir / "output" output_path = temp_dir / "output"
_init_project(output_path, "basic", overwrite=True) _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: def test_cli_boolean_flags_defaults_and_negation(self, temp_dir: Path) -> None:
"""init() should honor boolean defaults and negation when called directly.""" """init() should honor boolean defaults and negation when called directly."""
with patch("quickbot_cli.cli.TEMPLATES_DIR", temp_dir / "templates"): with patch("quickbot_cli.cli.TEMPLATES_DIR", temp_dir / "templates"):
template_dir = temp_dir / "templates" / "basic" template_dir = temp_dir / "templates" / "basic"
template_dir.mkdir(parents=True) 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( (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").mkdir()
(template_dir / "app" / "main.py.j2").write_text("ok") (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 / "locales" / "en").mkdir(parents=True, exist_ok=True)
(template_dir / "scripts").mkdir() (template_dir / "scripts").mkdir()
(template_dir / "scripts" / "babel_init.sh.j2").write_text("b") (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) # Default (both enabled)
out1 = temp_dir / "out1" out1 = temp_dir / "out1"
@@ -557,7 +640,8 @@ class TestCLIHelp:
result = cli_runner.invoke(app, ["--help"]) result = cli_runner.invoke(app, ["--help"])
assert result.exit_code == 0 assert result.exit_code == 0
# Check for the actual help text that appears # 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: def test_init_command_help(self, cli_runner: CliRunner) -> None:
"""Test that init command shows help information.""" """Test that init command shows help information."""
@@ -565,15 +649,15 @@ class TestCLIHelp:
result = cli_runner.invoke(app, ["init", "--help"]) result = cli_runner.invoke(app, ["init", "--help"])
assert result.exit_code == 0 assert result.exit_code == 0
# Check for the actual help text that appears # Check for the actual help text that appears
assert "OUTPUT" in result.output assert "--output" in result.output
assert "PATH" in result.output assert "Output directory" in result.output
def test_init_command_arguments(self, cli_runner: CliRunner) -> None: def test_init_command_arguments(self, cli_runner: CliRunner) -> None:
"""Test that init command accepts required arguments.""" """Test that init command accepts required arguments."""
# Test the actual CLI interface # Test the actual CLI interface
result = cli_runner.invoke(app, ["init", "--help"]) result = cli_runner.invoke(app, ["init", "--help"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "OUTPUT" in result.output assert "--output" in result.output
def test_cli_wrapper_function(self) -> None: def test_cli_wrapper_function(self) -> None:
"""Test that the CLI wrapper function exists and is callable.""" """Test that the CLI wrapper function exists and is callable."""

View File

@@ -20,7 +20,6 @@ import jinja2
from jinja2 import Environment, FileSystemLoader, StrictUndefined from jinja2 import Environment, FileSystemLoader, StrictUndefined
from quickbot_cli.cli import ( from quickbot_cli.cli import (
apply_optionals,
ask_variables, ask_variables,
load_template_spec, load_template_spec,
render_tree, render_tree,
@@ -237,38 +236,71 @@ class TestEdgeCases:
mock_subprocess_run.assert_not_called() mock_subprocess_run.assert_not_called()
def test_apply_optionals_with_missing_directories(self, temp_dir: Path) -> None: def test_post_tasks_with_missing_directories(self, temp_dir: Path) -> None:
"""Test apply_optionals with missing directories.""" """Post tasks should tolerate missing directories and files."""
# Don't create any directories, just test the function spec = {
apply_optionals(temp_dir, include_alembic=False, include_i18n=False) "post_tasks": [
{
# Should not raise any errors "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 assert True
def test_apply_optionals_with_partial_structure(self, temp_dir: Path) -> None: def test_post_tasks_with_partial_structure(self, temp_dir: Path) -> None:
"""Test apply_optionals with partial directory structure.""" """Post tasks should handle partial directory structure."""
# Create only some of the expected directories
(temp_dir / "alembic").mkdir() (temp_dir / "alembic").mkdir()
(temp_dir / "scripts").mkdir() (temp_dir / "scripts").mkdir()
(temp_dir / "scripts" / "migrations_generate.sh").write_text("script") (temp_dir / "scripts" / "migrations_generate.sh").write_text("script")
# Don't create locales or babel scripts spec = {
"post_tasks": [
apply_optionals(temp_dir, include_alembic=False, include_i18n=True) {
"when": "{{ not include_alembic }}",
# Alembic should be removed "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 / "alembic").exists()
assert not (temp_dir / "scripts" / "migrations_generate.sh").exists() assert not (temp_dir / "scripts" / "migrations_generate.sh").exists()
def test_apply_optionals_with_files_instead_of_directories(self, temp_dir: Path) -> None: def test_post_tasks_with_files_instead_of_directories(self, temp_dir: Path) -> None:
"""Test apply_optionals with files instead of directories.""" """Post tasks should remove files named like directories as well."""
# Create files with names that match expected directories
(temp_dir / "alembic").write_text("not a directory") (temp_dir / "alembic").write_text("not a directory")
(temp_dir / "locales").write_text("not a directory") (temp_dir / "locales").write_text("not a directory")
spec = {
apply_optionals(temp_dir, include_alembic=False, include_i18n=False) "post_tasks": [
{"when": "{{ not include_alembic }}", "run": ["rm", "-rf", "alembic"]},
# Files should be removed {"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 / "alembic").exists()
assert not (temp_dir / "locales").exists() assert not (temp_dir / "locales").exists()
@@ -302,12 +334,14 @@ class TestErrorHandling:
with pytest.raises((SystemExit, Exception)): with pytest.raises((SystemExit, Exception)):
ask_variables(spec, non_interactive) ask_variables(spec, non_interactive)
def test_render_tree_with_file_exists_error(self, temp_dir: Path) -> None: def test_render_tree_skips_existing_when_overwrite_disabled(
"""Test that render_tree raises FileExistsError when overwrite is 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 = temp_dir / "template"
template_root.mkdir() 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 }}')") template_file.write_text("app = FastAPI(title='{{ project_name }}')")
output_dir = temp_dir / "output" output_dir = temp_dir / "output"
@@ -320,8 +354,13 @@ class TestErrorHandling:
context: dict[str, Any] = {"project_name": "test"} context: dict[str, Any] = {"project_name": "test"}
env = Environment(autoescape=True) 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: 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.""" """Test that render_tree logs and re-raises when Jinja rendering fails."""
@@ -418,23 +457,23 @@ class TestBoundaryConditions:
) )
assert deep_file.exists() assert deep_file.exists()
def test_apply_optionals_with_many_files(self, temp_dir: Path) -> None: def test_post_tasks_with_many_files(self, temp_dir: Path) -> None:
"""Test apply_optionals with many files to process.""" """Post tasks should remove only specific optional module directories."""
# Create many files
for i in range(100): for i in range(100):
(temp_dir / f"file_{i}.txt").write_text(f"content {i}") (temp_dir / f"file_{i}.txt").write_text(f"content {i}")
# Create the expected structure
(temp_dir / "alembic").mkdir() (temp_dir / "alembic").mkdir()
(temp_dir / "locales").mkdir() (temp_dir / "locales").mkdir()
# Should not raise any errors spec = {
apply_optionals(temp_dir, include_alembic=False, include_i18n=False) "post_tasks": [
{"when": "{{ not include_alembic }}", "run": ["rm", "-rf", "alembic"]},
# Only the specific optional modules should be removed, not the random files {"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 / "alembic").exists()
assert not (temp_dir / "locales").exists() assert not (temp_dir / "locales").exists()
# Random files should still exist
assert (temp_dir / "file_0.txt").exists() assert (temp_dir / "file_0.txt").exists()
assert (temp_dir / "file_50.txt").exists() assert (temp_dir / "file_50.txt").exists()

View File

@@ -10,7 +10,8 @@ from pathlib import Path
from typing import Any from typing import Any
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest # pytest is used by the test framework
import typer
from quickbot_cli.cli import _init_project from quickbot_cli.cli import _init_project
@@ -203,9 +204,33 @@ class TestCLIIntegration:
spec_content = { spec_content = {
"variables": { "variables": {
"project_name": {"prompt": "Project name", "default": "simple_bot"}, "project_name": {"prompt": "Project name", "default": "simple_bot"},
"include_alembic": {"prompt": "Include Alembic?", "choices": ["yes", "no"], "default": "no"}, "include_alembic": {"prompt": "Include Alembic?", "choices": [True, False], "default": False},
"include_i18n": {"prompt": "Include i18n?", "choices": ["yes", "no"], "default": "no"}, "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" 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 "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() assert "existing content" not in (output_dir / "app" / "main.py").read_text()
def test_project_generation_without_overwrite_fails(self) -> None: def test_project_generation_without_overwrite_skips_existing(self) -> None:
"""Test that project generation fails without overwrite when files exist.""" """Project generation should skip existing files when overwrite is False."""
with tempfile.TemporaryDirectory() as tmp_dir: with tempfile.TemporaryDirectory() as tmp_dir:
tmp_path = Path(tmp_dir) tmp_path = Path(tmp_dir)
@@ -310,9 +335,12 @@ class TestCLIIntegration:
existing_file.parent.mkdir(parents=True, exist_ok=True) existing_file.parent.mkdir(parents=True, exist_ok=True)
existing_file.write_text("existing content") existing_file.write_text("existing content")
# Should fail with FileExistsError when overwrite is False # Should complete and keep the existing file content
with pytest.raises(FileExistsError): with patch("quickbot_cli.cli.typer.secho") as mock_secho:
_init_project(output_dir, "basic", project_name="overwrite_test") _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 # Check that file was not overwritten
assert "existing content" in (output_dir / "app" / "main.py").read_text() assert "existing content" in (output_dir / "app" / "main.py").read_text()