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

View File

@@ -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."""