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]
quickbot = "quickbot_cli.cli:main"
quickbot = "quickbot_cli.cli:app"
[project.optional-dependencies]
dev = [

View File

@@ -14,7 +14,7 @@ import typer
import yaml
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"
# 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
"""
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)
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
"""
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:
typer.secho(f"Value must be one of {choices}", fg=typer.colors.RED)
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")
if choices:
# Detect boolean-choice variables and coerce input accordingly
is_boolean_choices = all(isinstance(c, bool) for c in choices)
if is_boolean_choices:
if all(isinstance(c, bool) for c in choices):
val = _handle_boolean_choices(prompt=prompt, choices=choices, default=default)
else:
val = _handle_regular_choices(prompt=prompt, choices=choices, default=default)
@@ -189,6 +187,9 @@ def render_tree(
for item in template_root.iterdir():
if item.is_file():
# Skip __template__.yaml
if item.name == "__template__.yaml":
continue
if item.suffix == ".j2":
# Render template file
output_file = output_dir / item.stem
@@ -318,16 +319,24 @@ def _init_project(
@app.command()
def init(
output: Path = typer.Option(".", help="Output directory (defaults to current directory)"),
template: str = typer.Option(DEFAULT_TEMPLATE, "--template", "-t"),
project_name: str | None = typer.Option(None, help="Project name"),
description: str | None = typer.Option(None, help="Description"),
author: str | None = typer.Option(None, help="Author"),
license_name: str | None = typer.Option(None, help="License"),
output: Path = typer.Option(".", help="Output directory (defaults to current directory)", show_default=False),
template: str = typer.Option(DEFAULT_TEMPLATE, "--template", "-t", hidden=True),
project_name: str | None = typer.Option(None, help="Project name", show_default=False),
description: str | None = typer.Option(None, help="Description", show_default=False),
author: str | None = typer.Option(None, help="Author", show_default=False),
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_i18n: bool | None = typer.Option(None, help="Include i18n (will prompt if not specified)"),
include_alembic: bool | None = typer.Option(
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"),
interactive: bool = typer.Option(
default=True,
help="Interactive mode (if interactive is disabled, defaults will be taken from __template__.yaml)",
),
) -> None:
"""Initialize a new project in the specified directory."""
_init_project(
@@ -340,7 +349,7 @@ def init(
include_alembic=include_alembic,
include_i18n=include_i18n,
overwrite=overwrite,
interactive=True,
interactive=interactive,
)

View File

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

View File

@@ -630,6 +630,41 @@ post_tasks:
assert (out3 / "alembic").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:
"""Test CLI help and argument parsing."""
@@ -637,7 +672,7 @@ class TestCLIHelp:
def test_cli_help(self, cli_runner: CliRunner) -> None:
"""Test that CLI shows help information."""
# 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
# Check for the actual help text that appears
assert "init" in result.output
@@ -646,7 +681,7 @@ class TestCLIHelp:
def test_init_command_help(self, cli_runner: CliRunner) -> None:
"""Test that init command shows help information."""
# 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
# Check for the actual help text that appears
assert "--output" in result.output
@@ -655,7 +690,7 @@ class TestCLIHelp:
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"])
result = cli_runner.invoke(app, ["init", "--help"], env={"NO_COLOR": "1", "COLUMNS": "120"})
assert result.exit_code == 0
assert "--output" in result.output
@@ -702,6 +737,14 @@ class TestCLIHelp:
assert hasattr(init, "__name__")
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:
"""Test overwrite string parsing through the init function (covers conversion)."""