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
Some checks failed
CI / test (3.13) (push) Failing after 31s
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|
||||||
|
|
||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user