refactor: update CLI initialization and prompt handling for improved user experience
Some checks failed
CI / test (3.13) (push) Failing after 32s

This commit is contained in:
Alexander Kalinovsky
2025-08-27 19:37:52 +03:00
parent 62b12690c8
commit e76e6eed85
4 changed files with 72 additions and 20 deletions

View File

@@ -27,7 +27,7 @@ dependencies = [
] ]
[project.scripts] [project.scripts]
quickbot = "quickbot_cli.cli:main" quickbot = "quickbot_cli.cli:app"
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [

View File

@@ -14,7 +14,7 @@ import typer
import yaml import yaml
from jinja2 import Environment, FileSystemLoader, StrictUndefined from jinja2 import Environment, FileSystemLoader, StrictUndefined
app = typer.Typer(help="Project scaffolding CLI") app = typer.Typer(help="QuickBot CLI")
TEMPLATES_DIR = Path(__file__).parent / "templates" TEMPLATES_DIR = Path(__file__).parent / "templates"
# Module-level constants # Module-level constants
@@ -83,7 +83,7 @@ def _handle_boolean_choices(prompt: str, choices: list[bool], *, default: bool |
typer.Exit: If invalid input is provided typer.Exit: If invalid input is provided
""" """
raw = typer.prompt(f"{prompt} {choices} (default: {default})", default=default) raw = typer.prompt(f"{prompt} [y/n]", default=default)
coerced = _to_bool_like(value=raw) coerced = _to_bool_like(value=raw)
if coerced is None: if coerced is None:
@@ -110,7 +110,7 @@ def _handle_regular_choices(prompt: str, choices: list[str], default: str | None
typer.Exit: If invalid input is provided typer.Exit: If invalid input is provided
""" """
val: str = typer.prompt(f"{prompt} {choices} (default: {default})", default=default) val: str = typer.prompt(f"{prompt} {choices}", default=default)
if val not in choices: if val not in choices:
typer.secho(f"Value must be one of {choices}", fg=typer.colors.RED) typer.secho(f"Value must be one of {choices}", fg=typer.colors.RED)
raise typer.Exit(code=1) raise typer.Exit(code=1)
@@ -141,9 +141,7 @@ def ask_variables(spec: dict[str, Any], non_interactive: dict[str, Any]) -> dict
choices = meta.get("choices") choices = meta.get("choices")
if choices: if choices:
# Detect boolean-choice variables and coerce input accordingly if all(isinstance(c, bool) for c in choices):
is_boolean_choices = all(isinstance(c, bool) for c in choices)
if is_boolean_choices:
val = _handle_boolean_choices(prompt=prompt, choices=choices, default=default) val = _handle_boolean_choices(prompt=prompt, choices=choices, default=default)
else: else:
val = _handle_regular_choices(prompt=prompt, choices=choices, default=default) val = _handle_regular_choices(prompt=prompt, choices=choices, default=default)
@@ -189,6 +187,9 @@ def render_tree(
for item in template_root.iterdir(): for item in template_root.iterdir():
if item.is_file(): if item.is_file():
# Skip __template__.yaml
if item.name == "__template__.yaml":
continue
if item.suffix == ".j2": if item.suffix == ".j2":
# Render template file # Render template file
output_file = output_dir / item.stem output_file = output_dir / item.stem
@@ -318,16 +319,24 @@ def _init_project(
@app.command() @app.command()
def init( def init(
output: Path = typer.Option(".", help="Output directory (defaults to current directory)"), output: Path = typer.Option(".", help="Output directory (defaults to current directory)", show_default=False),
template: str = typer.Option(DEFAULT_TEMPLATE, "--template", "-t"), template: str = typer.Option(DEFAULT_TEMPLATE, "--template", "-t", hidden=True),
project_name: str | None = typer.Option(None, help="Project name"), project_name: str | None = typer.Option(None, help="Project name", show_default=False),
description: str | None = typer.Option(None, help="Description"), description: str | None = typer.Option(None, help="Description", show_default=False),
author: str | None = typer.Option(None, help="Author"), author: str | None = typer.Option(None, help="Author", show_default=False),
license_name: str | None = typer.Option(None, help="License"), license_name: str | None = typer.Option(None, help="License", show_default=False),
*, *,
include_alembic: bool | None = typer.Option(None, help="Include Alembic (will prompt if not specified)"), include_alembic: bool | None = typer.Option(
include_i18n: bool | None = typer.Option(None, help="Include i18n (will prompt if not specified)"), None, help="Include Alembic (will prompt if not specified)", show_default=False
),
include_i18n: bool | None = typer.Option(
None, help="Include i18n (will prompt if not specified)", show_default=False
),
overwrite: bool = typer.Option(default=False, help="Overwrite existing files"), overwrite: bool = typer.Option(default=False, help="Overwrite existing files"),
interactive: bool = typer.Option(
default=True,
help="Interactive mode (if interactive is disabled, defaults will be taken from __template__.yaml)",
),
) -> None: ) -> None:
"""Initialize a new project in the specified directory.""" """Initialize a new project in the specified directory."""
_init_project( _init_project(
@@ -340,7 +349,7 @@ def init(
include_alembic=include_alembic, include_alembic=include_alembic,
include_i18n=include_i18n, include_i18n=include_i18n,
overwrite=overwrite, overwrite=overwrite,
interactive=True, interactive=interactive,
) )

View File

@@ -20,7 +20,7 @@ TELEGRAM_WEBHOOK_DOMAIN = "example.com"
TELEGRAM_WEBHOOK_SCHEME = "https" TELEGRAM_WEBHOOK_SCHEME = "https"
TELEGRAM_WEBHOOK_PORT = 443 TELEGRAM_WEBHOOK_PORT = 443
TELEGRAM_WEBHOOK_AUTH_KEY = "changethis" TELEGRAM_WEBHOOK_AUTH_KEY = "changethis"
TELEGRAM_BOT_TOKEN "changethis" TELEGRAM_BOT_TOKEN = "changethis"
TELEGRAM_BOT_SERVER = "https://api.telegram.org" TELEGRAM_BOT_SERVER = "https://api.telegram.org"
TELEGRAM_BOT_SERVER_IS_LOCAL = False TELEGRAM_BOT_SERVER_IS_LOCAL = False

View File

@@ -630,6 +630,41 @@ post_tasks:
assert (out3 / "alembic").exists() assert (out3 / "alembic").exists()
assert not (out3 / "locales").exists() assert not (out3 / "locales").exists()
def test_init_command_project_name_fallback(self, temp_dir: Path) -> None:
"""Test that project_name falls back to output directory name when not provided."""
with patch("quickbot_cli.cli.TEMPLATES_DIR", temp_dir / "templates"):
# Create template structure
template_dir = temp_dir / "templates" / "basic"
template_dir.mkdir(parents=True)
# Create template spec WITHOUT project_name variable
spec_file = template_dir / "__template__.yaml"
spec_file.write_text("variables:\n description:\n prompt: Description\n default: A test project")
# Create template files
(template_dir / "app").mkdir()
(template_dir / "app" / "main.py.j2").write_text("app = FastAPI(title='{{ project_name }}')")
# Test with output directory that has a name (to test the fallback)
output_path = temp_dir / "my_project_output"
_init_project(
output=output_path,
template="basic",
project_name=None, # Explicitly set to None to trigger fallback
description="A test project",
author="Test Author",
license_name="MIT",
include_alembic=False,
include_i18n=False,
overwrite=False,
interactive=False, # Disable interactive mode to avoid prompting
)
# Verify that project_name was set to the output directory name
# This tests the fallback logic in line 297
assert output_path.exists()
# The project_name should be "my_project_output" (the directory name)
class TestCLIHelp: class TestCLIHelp:
"""Test CLI help and argument parsing.""" """Test CLI help and argument parsing."""
@@ -637,7 +672,7 @@ class TestCLIHelp:
def test_cli_help(self, cli_runner: CliRunner) -> None: def test_cli_help(self, cli_runner: CliRunner) -> None:
"""Test that CLI shows help information.""" """Test that CLI shows help information."""
# Test the actual CLI interface # Test the actual CLI interface
result = cli_runner.invoke(app, ["--help"]) result = cli_runner.invoke(app, ["--help"], env={"NO_COLOR": "1", "COLUMNS": "120"})
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" in result.output assert "init" in result.output
@@ -646,7 +681,7 @@ class TestCLIHelp:
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."""
# Test the actual CLI interface # Test the actual CLI interface
result = cli_runner.invoke(app, ["init", "--help"]) result = cli_runner.invoke(app, ["init", "--help"], env={"NO_COLOR": "1", "COLUMNS": "120"})
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
@@ -655,7 +690,7 @@ class TestCLIHelp:
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"], env={"NO_COLOR": "1", "COLUMNS": "120"})
assert result.exit_code == 0 assert result.exit_code == 0
assert "--output" in result.output assert "--output" in result.output
@@ -702,6 +737,14 @@ class TestCLIHelp:
assert hasattr(init, "__name__") assert hasattr(init, "__name__")
assert init.__name__ == "init" assert init.__name__ == "init"
def test_version_command(self, cli_runner: CliRunner) -> None:
"""Test that version command works and shows version information."""
# Test the actual CLI interface
result = cli_runner.invoke(app, ["version"], env={"NO_COLOR": "1", "COLUMNS": "120"})
assert result.exit_code == 0
# Check that it shows version information
assert "quickbot-cli version" in result.output
class TestCLIOverwriteParsing: class TestCLIOverwriteParsing:
"""Test overwrite string parsing through the init function (covers conversion).""" """Test overwrite string parsing through the init function (covers conversion)."""