refactor: update CLI initialization and prompt handling for improved user experience
Some checks failed
CI / test (3.13) (push) Failing after 32s
Some checks failed
CI / test (3.13) (push) Failing after 32s
This commit is contained in:
@@ -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 = [
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)."""
|
||||||
|
|||||||
Reference in New Issue
Block a user