diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..205becd --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,34 @@ +name: Test + +on: + push: + branches: + - main + pull_request: + +concurrency: test-${{ github.sha }} + +jobs: + test: + runs-on: ubuntu-latest + + env: + PYTHON_VERSION: "3.13" + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Set up Python ${{ env.PYTHON_VERSION }} + uses: actions/setup-python@v6 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install dependencies + run: uv sync + + - name: Run tests + run: uv run pytest -v diff --git a/.gitignore b/.gitignore index 1a40bd4..79e4151 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ wheels/ docs/prd .geminiignore .agent/ +.opencode/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4422c26..9e24e92 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,6 +28,6 @@ repos: hooks: - id: codespell exclude: "uv.lock" - args: ["--ignore-words-list=ure"] + args: ["--ignore-words-list=ure,caf"] additional_dependencies: - tomli diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5341787..7db0369 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,30 +23,25 @@ src/ │ ├── gitignore.jinja │ ├── env.example.jinja │ └── readme.md.jinja + ├── App/ # Core application templates + │ └── *.jinja ├── Containers/ # Docker templates │ ├── Dockerfile.jinja │ ├── docker-compose.yml.jinja │ └── docker-compose.infra.yml.jinja - ├── app/ # Core application templates - │ ├── __init__.py.jinja - │ ├── config.py.jinja - │ └── main.py.jinja └── Plugins/ # Optional plugin templates ├── __init__.py - ├── SQLAlchemy/ + ├── AdvancedAlchemy/ │ ├── __init__.py │ └── Templates/ - │ └── db/ - │ ├── __init__.py.jinja - │ ├── config.py.jinja - │ └── models.py.jinja - └── JWT/ - ├── __init__.py - └── Templates/ - └── auth/ - ├── __init__.py.jinja - ├── guards.py.jinja - └── schemas.py.jinja + ├── LitestarSAQ/ + │ ├── __init__.py + │ └── Templates/ + ├── LitestarVite/ + │ ├── __init__.py + │ └── Templates/ + └── LitestarGranian/ + └── __init__.py ``` ## Core Components @@ -73,7 +68,7 @@ Uses **msgspec** for efficient data serialization. Key models: - **Framework** - Enum of supported frameworks (Litestar, future: FastAPI) - **Database** - Enum of database options (PostgreSQL, SQLite, MySQL, None) -- **Plugin** - Enum of available plugins (SQLAlchemy, JWT) +- **MemoryStore** - Enum of memory store options (Redis, Valkey, None) - **ProjectConfig** - Main configuration struct containing all user choices - **DatabaseConfig** - Database-specific configuration (driver, port, URL) @@ -104,27 +99,41 @@ All templates use **Jinja2** with `.jinja` extension. Template context includes: ### Adding a New Plugin -1. Create plugin directory: +1. Create plugin directory under the framework's `Plugins/` directory: ``` - Litestar/Plugins/NewPlugin/ + src/Litestar/Plugins/NewPlugin/ ├── __init__.py └── Templates/ - └── new_module/ - └── *.jinja + └── *.jinja ``` -2. Add to `Plugin` enum in `models.py`: +2. In `__init__.py`, create a class that extends `BasePlugin`: ```python - class Plugin(StrEnum): - ADVANCED_ALCHEMY = "AdvancedAlchemy" - LITESTAR_SAQ = "LitestarSAQ" - LITESTAR_VITE = "LitestarVite" - NEW_PLUGIN = "NewPlugin" # Add this + from src.models import ProjectConfig + from src.plugin import BasePlugin + + + class NewPlugin(BasePlugin): + """Description of the plugin.""" + + @property + def name(self) -> str: + """Get the plugin display name.""" + return "New Plugin" + + @property + def description(self) -> str: + """Get the plugin description.""" + return "Description for the CLI" + + def is_applicable(self, config: ProjectConfig) -> bool: + """Check if this plugin is applicable.""" + return True # or check config fields ``` -3. Update CLI in `cli.py` to include the new option +3. The plugin will be automatically discovered by `discover_plugins()`. No enum or CLI changes are needed. -4. Update base templates if the plugin requires imports/configuration changes +4. Add Jinja2 templates in the `Templates/` subdirectory. They will be rendered into `src/backend/` of the generated project. ### Adding a New Framework @@ -190,18 +199,17 @@ A typical generated project looks like: ``` my_project/ -├── app/ -│ ├── __init__.py -│ ├── config.py -│ └── main.py -├── models/ # If AdvancedAlchemy selected -│ ├── __init__.py -│ ├── config.py -│ └── models.py -├── auth/ # If JWT selected -│ ├── __init__.py -│ ├── guards.py -│ └── schemas.py +├── src/ +│ └── backend/ +│ ├── __init__.py +│ ├── app.py +│ ├── config.py +│ ├── models/ # If AdvancedAlchemy selected +│ │ └── users.py +│ └── lib/ # If plugins are selected +│ ├── dependencies.py +│ ├── services.py +│ └── tasks.py # If SAQ selected ├── .env.example ├── .gitignore ├── pyproject.toml @@ -221,8 +229,6 @@ my_project/ ## Future Improvements - [ ] Add FastAPI framework support -- [ ] Add more plugins (Structlog, Redis, CORS) -- [ ] Add Alembic migrations setup -- [ ] Add test scaffolding -- [ ] Add GitHub Actions workflows -- [ ] Add pre-commit configuration +- [ ] Add more plugins (Structlog, CORS) +- [ ] Add test scaffolding for generated projects +- [ ] Add CI workflow for generated projects diff --git a/README.md b/README.md index f64bae0..cbec297 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,76 @@ # Litestar Start -Interactive CLI to scaffold fullstack projects with modular choices +Interactive CLI to scaffold fullstack [Litestar](https://litestar.dev) projects with modular choices. + +## Features + +- **Interactive prompts** — guided setup with [questionary](https://questionary.readthedocs.io/) +- **Database support** — PostgreSQL, MySQL, or SQLite via AdvancedAlchemy +- **Memory stores** — Redis or Valkey for caching / background tasks +- **Plugin system** — modular plugins that add functionality: + - **AdvancedAlchemy** — SQLAlchemy ORM integration with models, services, and dependencies + - **Litestar SAQ** — background task queue powered by SAQ (requires a memory store) + - **Litestar Vite** — frontend asset bundling with Vite + - **Litestar Granian** — high-performance Granian ASGI server +- **Docker** — optional Dockerfile and `docker-compose.infra.yml` for local development +- **Post-generation setup** — automatic `git init`, `uv sync`, Docker infrastructure startup, and import sorting + +## Requirements + +- Python 3.13+ +- [uv](https://docs.astral.sh/uv/) (used for dependency management in generated projects) ## Installation ```bash uvx litestar-start ``` + +Or install globally: + +```bash +uv tool install litestar-start +``` + +## Usage + +Run the CLI and follow the interactive prompts: + +```bash +litestar-start +``` + +You will be asked to: + +1. Enter a project name +2. Select a database (PostgreSQL, SQLite, MySQL, or None) +3. Select a memory store (Redis, Valkey, or None) +4. Choose plugins (based on your database/store choices) +5. Configure Docker options +6. Confirm and generate + +The generated project includes a working Litestar application with your selected options pre-configured. + +## Development + +```bash +git clone https://github.com/Harshal6927/litestar-start.git +cd litestar-start +uv sync +``` + +Run tests: + +```bash +pytest +``` + +Run linters: + +```bash +make lint +``` + +## License + +MIT diff --git a/src/Litestar/Plugins/AdvancedAlchemy/__init__.py b/src/Litestar/Plugins/AdvancedAlchemy/__init__.py index 186ecbb..1496b17 100644 --- a/src/Litestar/Plugins/AdvancedAlchemy/__init__.py +++ b/src/Litestar/Plugins/AdvancedAlchemy/__init__.py @@ -6,12 +6,33 @@ class AdvancedAlchemyPlugin(BasePlugin): """Litestar plugin providing AdvancedAlchemy integration.""" @property - def name(self) -> str: # noqa: D102 + def name(self) -> str: + """Get the plugin display name. + + Returns: + The display name shown in the CLI. + + """ return "AdvancedAlchemy (ORM)" @property - def description(self) -> str: # noqa: D102 + def description(self) -> str: + """Get the plugin description. + + Returns: + A short description of the plugin. + + """ return "SQLAlchemy integration with Litestar" - def is_applicable(self, config: ProjectConfig) -> bool: # noqa: D102, PLR6301 + def is_applicable(self, config: ProjectConfig) -> bool: # noqa: PLR6301 + """Check if this plugin is applicable for the given configuration. + + Args: + config: The project configuration. + + Returns: + True if a database (other than None) is selected. + + """ return config.database != Database.NONE diff --git a/src/Litestar/Plugins/LitestarSAQ/__init__.py b/src/Litestar/Plugins/LitestarSAQ/__init__.py index 975bb9f..8ce27c6 100644 --- a/src/Litestar/Plugins/LitestarSAQ/__init__.py +++ b/src/Litestar/Plugins/LitestarSAQ/__init__.py @@ -6,12 +6,33 @@ class LitestarSAQPlugin(BasePlugin): """SAQ integration plugin for Litestar background tasks.""" @property - def name(self) -> str: # noqa: D102 + def name(self) -> str: + """Get the plugin display name. + + Returns: + The display name shown in the CLI. + + """ return "Litestar SAQ (Background Tasks)" @property - def description(self) -> str: # noqa: D102 + def description(self) -> str: + """Get the plugin description. + + Returns: + A short description of the plugin. + + """ return "SAQ integration for background tasks in Litestar" - def is_applicable(self, config: ProjectConfig) -> bool: # noqa: D102, PLR6301 + def is_applicable(self, config: ProjectConfig) -> bool: # noqa: PLR6301 + """Check if this plugin is applicable for the given configuration. + + Args: + config: The project configuration. + + Returns: + True if a memory store (other than None) is selected. + + """ return config.memory_store != MemoryStore.NONE diff --git a/src/Litestar/Plugins/LitestarVite/__init__.py b/src/Litestar/Plugins/LitestarVite/__init__.py index bd44146..13dbd58 100644 --- a/src/Litestar/Plugins/LitestarVite/__init__.py +++ b/src/Litestar/Plugins/LitestarVite/__init__.py @@ -9,11 +9,23 @@ class LitestarVitePlugin(BasePlugin): """Plugin providing Vite integration for Litestar frontend assets.""" @property - def name(self) -> str: # noqa: D102 + def name(self) -> str: + """Get the plugin display name. + + Returns: + The display name shown in the CLI. + + """ return "Litestar Vite (Frontend Integration)" @property - def description(self) -> str: # noqa: D102 + def description(self) -> str: + """Get the plugin description. + + Returns: + A short description of the plugin. + + """ return "Vite integration for frontend assets in Litestar" def post_generate(self, config: ProjectConfig, output_dir: Path) -> None: # noqa: ARG002 diff --git a/src/Litestar/generator.py b/src/Litestar/generator.py index d120a00..b9b0ff5 100644 --- a/src/Litestar/generator.py +++ b/src/Litestar/generator.py @@ -69,7 +69,22 @@ def _render_templates( context: dict, root_template_dir: Path | None = None, ) -> None: - """Recursively render templates from a directory.""" + """Recursively render Jinja2 templates from a directory into the output. + + Walks `template_dir`, rendering every `*.jinja` file with the given + context and writing the result (sans `.jinja` suffix) into `output_subdir`. + Subdirectories are traversed recursively. + + Args: + template_dir: Directory containing `.jinja` template files. + output_subdir: Target directory where rendered files are written. + env: The Jinja2 environment used for template loading. + context: Template variables passed to each render call. + root_template_dir: The root of the template tree, used to compute + template names relative to the Jinja2 loader. Defaults to + `template_dir` on first call and is preserved during recursion. + + """ if root_template_dir is None: root_template_dir = template_dir diff --git a/src/cli.py b/src/cli.py index 626d9cf..4819cba 100644 --- a/src/cli.py +++ b/src/cli.py @@ -14,6 +14,17 @@ from src.plugin import Plugin, discover_plugins from src.utils import validate_project_name +__all__ = [ + "ask_database", + "ask_docker", + "ask_framework", + "ask_memory_store", + "ask_plugins", + "ask_project_name", + "main", + "run_post_generation_setup", +] + console = Console() @@ -250,7 +261,7 @@ def run_post_generation_setup(generator: ProjectGenerator, output_dir: Path) -> # Sort imports with console.status("[bold green]Sorting imports with isort..."): - subprocess.run(["ruff", "check", "--select", "I", "--fix", "."], cwd=output_dir, check=True) # noqa: S607 + subprocess.run(["uv", "run", "ruff", "check", "--select", "I", "--fix", "."], cwd=output_dir, check=True) # noqa: S607 # Ask if user wants to start the application console.print() diff --git a/src/models.py b/src/models.py index 1fc2157..1d622ff 100644 --- a/src/models.py +++ b/src/models.py @@ -6,6 +6,17 @@ import msgspec +from src.utils import slugify + +__all__ = [ + "Database", + "DatabaseConfig", + "Framework", + "MemoryStore", + "MemoryStoreConfig", + "ProjectConfig", +] + class Framework(StrEnum): """Supported backend frameworks.""" @@ -44,7 +55,7 @@ class ProjectConfig(msgspec.Struct): @property def slug(self) -> str: """Return project name as a valid Python package name.""" - return self.name.lower().replace("-", "_").replace(" ", "_") + return slugify(self.name) def has_plugin(self, plugin_id: str) -> bool: """Check if a plugin is enabled. diff --git a/src/plugin.py b/src/plugin.py index 3eee692..8bbef77 100644 --- a/src/plugin.py +++ b/src/plugin.py @@ -8,6 +8,13 @@ from src.models import ProjectConfig +__all__ = [ + "BasePlugin", + "Plugin", + "camel_to_snake", + "discover_plugins", +] + def camel_to_snake(name: str) -> str: """Convert CamelCase to snake_case. diff --git a/src/utils.py b/src/utils.py index 027e1fe..b99f549 100644 --- a/src/utils.py +++ b/src/utils.py @@ -5,7 +5,14 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape -MIN_PROJECT_NAME_LENGTH = 1 +__all__ = [ + "get_package_dir", + "get_template_env", + "slugify", + "validate_project_name", + "write_file", +] + MAX_PROJECT_NAME_LENGTH = 50 @@ -72,8 +79,6 @@ def validate_project_name(name: str) -> str | None: """ if not name: return "Project name cannot be empty" - if len(name) < MIN_PROJECT_NAME_LENGTH: - return f"Project name must be at least {MIN_PROJECT_NAME_LENGTH} characters" if len(name) > MAX_PROJECT_NAME_LENGTH: return f"Project name must be less than {MAX_PROJECT_NAME_LENGTH} characters" # Check if slugified name is valid @@ -83,28 +88,7 @@ def validate_project_name(name: str) -> str | None: return None -def create_directory(path: Path) -> None: - """Create a directory if it doesn't exist.""" - path.mkdir(parents=True, exist_ok=True) - - def write_file(path: Path, content: str) -> None: """Write content to a file, creating parent directories if needed.""" path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8") - - -def render_template(env: Environment, template_name: str, context: dict) -> str: - """Render a Jinja2 template with the given context. - - Args: - env: The Jinja2 environment. - template_name: The name of the template to render. - context: The context dictionary to render the template with. - - Returns: - The rendered template string. - - """ - template = env.get_template(template_name) - return template.render(**context) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1cd14f3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,61 @@ +"""Shared test fixtures.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from src.models import Database, Framework, MemoryStore, ProjectConfig + + +@pytest.fixture +def make_config() -> Callable[..., ProjectConfig]: + """Factory fixture to create ProjectConfig with sensible defaults. + + Returns: + A callable that creates ProjectConfig instances with overridable defaults. + + """ + + def _make_config( # noqa: PLR0913, PLR0917 + name: str = "Test", + framework: Framework = Framework.LITESTAR, + database: Database = Database.NONE, + memory_store: MemoryStore = MemoryStore.NONE, + plugins: list[str] | None = None, + docker: bool = False, # noqa: FBT001, FBT002 + docker_infra: bool = False, # noqa: FBT001, FBT002 + ) -> ProjectConfig: + return ProjectConfig( + name=name, + framework=framework, + database=database, + memory_store=memory_store, + plugins=plugins or [], + docker=docker, + docker_infra=docker_infra, + ) + + return _make_config + + +@pytest.fixture +def sample_config(make_config: Callable[..., ProjectConfig]) -> ProjectConfig: + """A minimal default ProjectConfig for simple tests. + + Returns: + A ProjectConfig with all defaults (no database, no plugins, no docker). + + """ + return make_config() + + +@pytest.fixture +def output_dir(tmp_path: Path) -> Path: + """Provide a clean temporary output directory for generators. + + Returns: + A Path to a temporary directory. + + """ + return tmp_path diff --git a/tests/test_cli.py b/tests/test_cli.py index 985a0ce..31ae913 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -13,9 +13,11 @@ ask_memory_store, ask_plugins, ask_project_name, + main, run_post_generation_setup, ) from src.generator import ProjectGenerator +from src.Litestar.generator import LitestarGenerator from src.models import Database, Framework, MemoryStore, ProjectConfig from src.plugin import BasePlugin @@ -371,7 +373,7 @@ def test_runs_git_init(self, tmp_path: Path, mocker: MockerFixture) -> None: docker_infra=False, ) generator = ProjectGenerator(config, tmp_path) - generator._framework_generator = mocker.Mock() + generator._framework_generator = mocker.Mock(spec=LitestarGenerator) run_post_generation_setup(generator, tmp_path) @@ -396,7 +398,7 @@ def test_runs_uv_sync(self, tmp_path: Path, mocker: MockerFixture) -> None: docker_infra=False, ) generator = ProjectGenerator(config, tmp_path) - generator._framework_generator = mocker.Mock() + generator._framework_generator = mocker.Mock(spec=LitestarGenerator) run_post_generation_setup(generator, tmp_path) @@ -420,7 +422,7 @@ def test_runs_docker_compose_when_docker_infra(self, tmp_path: Path, mocker: Moc docker_infra=True, ) generator = ProjectGenerator(config, tmp_path) - generator._framework_generator = mocker.Mock() + generator._framework_generator = mocker.Mock(spec=LitestarGenerator) run_post_generation_setup(generator, tmp_path) @@ -445,7 +447,7 @@ def test_skips_docker_compose_when_not_needed(self, tmp_path: Path, mocker: Mock docker_infra=True, ) generator = ProjectGenerator(config, tmp_path) - generator._framework_generator = mocker.Mock() + generator._framework_generator = mocker.Mock(spec=LitestarGenerator) run_post_generation_setup(generator, tmp_path) @@ -473,7 +475,7 @@ def test_copies_gitignore_to_dockerignore_when_docker(self, tmp_path: Path, mock docker_infra=False, ) generator = ProjectGenerator(config, tmp_path) - generator._framework_generator = mocker.Mock() + generator._framework_generator = mocker.Mock(spec=LitestarGenerator) run_post_generation_setup(generator, tmp_path) @@ -521,13 +523,229 @@ def test_runs_ruff_import_sorting(self, tmp_path: Path, mocker: MockerFixture) - docker_infra=False, ) generator = ProjectGenerator(config, tmp_path) - generator._framework_generator = mocker.Mock() + generator._framework_generator = mocker.Mock(spec=LitestarGenerator) run_post_generation_setup(generator, tmp_path) # Find the ruff call - ruff_calls = [call for call in mock_run.call_args_list if call[0][0][0] == "ruff"] + ruff_calls = [call for call in mock_run.call_args_list if "ruff" in call[0][0]] assert len(ruff_calls) >= 1 assert "check" in ruff_calls[0][0][0] assert "--select" in ruff_calls[0][0][0] assert "I" in ruff_calls[0][0][0] + + def test_copies_env_example_to_env(self, tmp_path: Path, mocker: MockerFixture) -> None: + """Verify run_post_generation_setup copies .env.example to .env.""" + mocker.patch("subprocess.run") + mock_confirm = mocker.patch("questionary.confirm") + mock_confirm.return_value.ask.return_value = False + + # Create .env.example + env_example = tmp_path / ".env.example" + env_example.write_text("DATABASE_URL=sqlite:///app.db\n") + + config = ProjectConfig( + name="Test", + framework=Framework.LITESTAR, + database=Database.NONE, + memory_store=MemoryStore.NONE, + plugins=[], + docker=False, + docker_infra=False, + ) + generator = ProjectGenerator(config, tmp_path) + generator._framework_generator = mocker.Mock(spec=LitestarGenerator) + + run_post_generation_setup(generator, tmp_path) + + env_file = tmp_path / ".env" + assert env_file.exists() + assert env_file.read_text() == "DATABASE_URL=sqlite:///app.db\n" + + def test_skips_env_copy_when_no_example(self, tmp_path: Path, mocker: MockerFixture) -> None: + """Verify run_post_generation_setup skips .env copy when .env.example doesn't exist.""" + mocker.patch("subprocess.run") + mock_confirm = mocker.patch("questionary.confirm") + mock_confirm.return_value.ask.return_value = False + + config = ProjectConfig( + name="Test", + framework=Framework.LITESTAR, + database=Database.NONE, + memory_store=MemoryStore.NONE, + plugins=[], + docker=False, + docker_infra=False, + ) + generator = ProjectGenerator(config, tmp_path) + generator._framework_generator = mocker.Mock(spec=LitestarGenerator) + + run_post_generation_setup(generator, tmp_path) + + env_file = tmp_path / ".env" + assert not env_file.exists() + + def test_skips_dockerignore_when_no_docker(self, tmp_path: Path, mocker: MockerFixture) -> None: + """Verify .dockerignore is NOT created when docker is False.""" + mocker.patch("subprocess.run") + mock_confirm = mocker.patch("questionary.confirm") + mock_confirm.return_value.ask.return_value = False + + # Create .gitignore but docker=False + gitignore = tmp_path / ".gitignore" + gitignore.write_text("*.pyc\n") + + config = ProjectConfig( + name="Test", + framework=Framework.LITESTAR, + database=Database.NONE, + memory_store=MemoryStore.NONE, + plugins=[], + docker=False, + docker_infra=False, + ) + generator = ProjectGenerator(config, tmp_path) + generator._framework_generator = mocker.Mock(spec=LitestarGenerator) + + run_post_generation_setup(generator, tmp_path) + + dockerignore = tmp_path / ".dockerignore" + assert not dockerignore.exists() + + def test_skips_dockerignore_when_no_gitignore(self, tmp_path: Path, mocker: MockerFixture) -> None: + """Verify .dockerignore is NOT created when .gitignore doesn't exist.""" + mocker.patch("subprocess.run") + mock_confirm = mocker.patch("questionary.confirm") + mock_confirm.return_value.ask.return_value = False + + config = ProjectConfig( + name="Test", + framework=Framework.LITESTAR, + database=Database.NONE, + memory_store=MemoryStore.NONE, + plugins=[], + docker=True, + docker_infra=False, + ) + generator = ProjectGenerator(config, tmp_path) + generator._framework_generator = mocker.Mock(spec=LitestarGenerator) + + run_post_generation_setup(generator, tmp_path) + + dockerignore = tmp_path / ".dockerignore" + assert not dockerignore.exists() + + +class TestSubprocessFailures: + """Tests for subprocess failure paths in run_post_generation_setup.""" + + def test_git_init_failure_raises(self, tmp_path: Path, mocker: MockerFixture) -> None: + """Verify CalledProcessError from git init propagates.""" + import subprocess as sp + + mock_run = mocker.patch("subprocess.run") + mock_run.side_effect = sp.CalledProcessError(128, ["git", "init"]) + + config = ProjectConfig( + name="Test", + framework=Framework.LITESTAR, + database=Database.NONE, + memory_store=MemoryStore.NONE, + plugins=[], + docker=False, + docker_infra=False, + ) + generator = ProjectGenerator(config, tmp_path) + generator._framework_generator = mocker.Mock(spec=LitestarGenerator) + + with pytest.raises(sp.CalledProcessError): + run_post_generation_setup(generator, tmp_path) + + def test_uv_sync_failure_raises(self, tmp_path: Path, mocker: MockerFixture) -> None: + """Verify CalledProcessError from uv sync propagates.""" + import subprocess as sp + + call_count = 0 + uv_sync_call_number = 2 + + def selective_fail(*args: object, **kwargs: object) -> None: # noqa: ARG001 + nonlocal call_count + call_count += 1 + if call_count == uv_sync_call_number: # uv sync is the second subprocess call + raise sp.CalledProcessError(1, ["uv", "sync"]) + + mocker.patch("subprocess.run", side_effect=selective_fail) + + config = ProjectConfig( + name="Test", + framework=Framework.LITESTAR, + database=Database.NONE, + memory_store=MemoryStore.NONE, + plugins=[], + docker=False, + docker_infra=False, + ) + generator = ProjectGenerator(config, tmp_path) + generator._framework_generator = mocker.Mock(spec=LitestarGenerator) + + with pytest.raises(sp.CalledProcessError): + run_post_generation_setup(generator, tmp_path) + + +class TestMain: + """Tests for main() CLI entry point.""" + + def test_main_happy_path(self, tmp_path: Path, mocker: MockerFixture) -> None: + """Verify main() orchestrates the full project generation flow.""" + mocker.patch("src.cli.print_banner") + mocker.patch("src.cli.ask_project_name", return_value="test-project") + mocker.patch("src.cli.ask_framework", return_value=Framework.LITESTAR) + mocker.patch("src.cli.ask_database", return_value=Database.NONE) + mocker.patch("src.cli.ask_memory_store", return_value=MemoryStore.NONE) + mocker.patch("src.cli.discover_plugins", return_value=[]) + mocker.patch("src.cli.ask_plugins", return_value=[]) + mocker.patch("src.cli.ask_docker", return_value=(False, False)) + + mock_confirm = mocker.patch("questionary.confirm") + mock_confirm.return_value.ask.return_value = True # Confirm generation + + mock_generator_cls = mocker.patch("src.cli.ProjectGenerator") + mock_generator = mock_generator_cls.return_value + + # Mock Path.cwd() to use tmp_path + mocker.patch("src.cli.Path.cwd", return_value=tmp_path) + + mock_post_gen = mocker.patch("src.cli.run_post_generation_setup") + + main() + + mock_generator_cls.assert_called_once() + mock_generator.generate.assert_called_once() + mock_post_gen.assert_called_once() + + def test_main_user_cancels_confirmation(self, mocker: MockerFixture) -> None: + """Verify main() exits when user declines confirmation.""" + mocker.patch("src.cli.print_banner") + mocker.patch("src.cli.ask_project_name", return_value="test-project") + mocker.patch("src.cli.ask_framework", return_value=Framework.LITESTAR) + mocker.patch("src.cli.ask_database", return_value=Database.NONE) + mocker.patch("src.cli.ask_memory_store", return_value=MemoryStore.NONE) + mocker.patch("src.cli.discover_plugins", return_value=[]) + mocker.patch("src.cli.ask_plugins", return_value=[]) + mocker.patch("src.cli.ask_docker", return_value=(False, False)) + + mock_confirm = mocker.patch("questionary.confirm") + mock_confirm.return_value.ask.return_value = False # Decline + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 0 + + def test_main_keyboard_interrupt(self, mocker: MockerFixture) -> None: + """Verify main() handles KeyboardInterrupt gracefully.""" + mocker.patch("src.cli.print_banner") + mocker.patch("src.cli.ask_project_name", side_effect=KeyboardInterrupt) + + # Should not raise — main() catches KeyboardInterrupt + main() diff --git a/tests/test_granian_plugin.py b/tests/test_granian_plugin.py new file mode 100644 index 0000000..be1c893 --- /dev/null +++ b/tests/test_granian_plugin.py @@ -0,0 +1,66 @@ +# ruff: noqa: PLR6301 +"""Unit tests for LitestarGranianPlugin.""" + +from src.Litestar.Plugins.LitestarGranian import LitestarGranianPlugin +from src.models import Database, Framework, MemoryStore, ProjectConfig + + +class TestLitestarGranianPlugin: + """Tests for LitestarGranianPlugin.""" + + def test_plugin_id(self) -> None: + """Verify plugin ID is derived correctly from class name.""" + plugin = LitestarGranianPlugin() + assert plugin.id == "litestar_granian" + + def test_plugin_name(self) -> None: + """Verify plugin display name.""" + plugin = LitestarGranianPlugin() + assert plugin.name == "Litestar Granian (Server)" + + def test_plugin_description(self) -> None: + """Verify plugin description is set.""" + plugin = LitestarGranianPlugin() + assert "Granian" in plugin.description + + def test_is_applicable_always_true(self) -> None: + """Verify Granian plugin is always applicable (inherits BasePlugin default).""" + plugin = LitestarGranianPlugin() + config = ProjectConfig( + name="Test", + framework=Framework.LITESTAR, + database=Database.NONE, + memory_store=MemoryStore.NONE, + plugins=[], + docker=False, + docker_infra=False, + ) + assert plugin.is_applicable(config) is True + + def test_is_applicable_with_database(self) -> None: + """Verify Granian plugin is applicable even with database configured.""" + plugin = LitestarGranianPlugin() + config = ProjectConfig( + name="Test", + framework=Framework.LITESTAR, + database=Database.POSTGRESQL, + memory_store=MemoryStore.REDIS, + plugins=["litestar_granian"], + docker=True, + docker_infra=True, + ) + assert plugin.is_applicable(config) is True + + def test_get_template_context_empty(self) -> None: + """Verify Granian plugin returns empty template context (inherits default).""" + plugin = LitestarGranianPlugin() + config = ProjectConfig( + name="Test", + framework=Framework.LITESTAR, + database=Database.NONE, + memory_store=MemoryStore.NONE, + plugins=[], + docker=False, + docker_infra=False, + ) + assert plugin.get_template_context(config) == {} diff --git a/tests/test_generator.py b/tests/test_litestar_generator.py similarity index 89% rename from tests/test_generator.py rename to tests/test_litestar_generator.py index f0e0fc5..a3ae572 100644 --- a/tests/test_generator.py +++ b/tests/test_litestar_generator.py @@ -1,5 +1,7 @@ from pathlib import Path +from pytest_mock import MockerFixture + from src.Litestar.generator import LitestarGenerator from src.models import Database, Framework, MemoryStore, ProjectConfig @@ -180,7 +182,7 @@ def test_litestar_generator_saq_rendering(tmp_path: Path) -> None: # Verify SAQ plugin in app.py app_content = (tmp_path / "src" / "backend" / "app.py").read_text() assert "from src.backend.config import saq" in app_content - assert "saq," in app_content or "plugins=[\n saq" in app_content + assert "saq" in app_content.lower(), "SAQ plugin reference not found in app.py" # Verify SAQ dependency in pyproject.toml pyproject_content = (tmp_path / "pyproject.toml").read_text() @@ -358,7 +360,8 @@ def test_litestar_generator_dockerfile_rendering(tmp_path: Path) -> None: assert "CMD" in content # Check for database migration command when advanced_alchemy is enabled - assert "litestar database upgrade" in content or "alembic upgrade head" in content + has_migration_cmd = "litestar database upgrade" in content or "alembic upgrade head" in content + assert has_migration_cmd, "No database migration command found in Dockerfile" def test_litestar_generator_docker_infra_content(tmp_path: Path) -> None: @@ -448,3 +451,48 @@ def test_litestar_generator_vite_context(tmp_path: Path) -> None: context = generator._get_template_context() assert context["litestar_vite"] is True + + +def test_litestar_generator_post_generate_no_plugins(tmp_path: Path) -> None: + """Verify post_generate does nothing when no plugins are enabled.""" + config = ProjectConfig( + name="Post Gen Test", + framework=Framework.LITESTAR, + database=Database.NONE, + memory_store=MemoryStore.NONE, + plugins=[], + docker=False, + docker_infra=False, + ) + + generator = LitestarGenerator(config, tmp_path) + # Should not raise + generator.post_generate() + + +def test_litestar_generator_post_generate_calls_plugin(tmp_path: Path, mocker: MockerFixture) -> None: + """Verify post_generate calls post_generate on each enabled plugin.""" + config = ProjectConfig( + name="Post Gen Test", + framework=Framework.LITESTAR, + database=Database.POSTGRESQL, + memory_store=MemoryStore.NONE, + plugins=["advanced_alchemy"], + docker=False, + docker_infra=False, + ) + + generator = LitestarGenerator(config, tmp_path) + + # Mock all discovered plugins' post_generate + for plugin in generator.plugins: + mocker.patch.object(plugin, "post_generate") + + generator.post_generate() + + # Verify the enabled plugin's post_generate was called + for plugin in generator.plugins: + if config.has_plugin(plugin.id): + plugin.post_generate.assert_called_once_with(config, tmp_path) # type: ignore[unresolved-attribute] + else: + plugin.post_generate.assert_not_called() # type: ignore[unresolved-attribute] diff --git a/tests/test_models.py b/tests/test_models.py index 22a7efe..431b97b 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -195,6 +195,45 @@ def test_needs_docker_infra_true_with_postgres_and_redis(self) -> None: ) assert config.needs_docker_infra is True + def test_slug_removes_special_characters(self) -> None: + """Verify slug removes special characters like @, #, $.""" + config = ProjectConfig( + name="my@project#name", + framework=Framework.LITESTAR, + database=Database.NONE, + memory_store=MemoryStore.NONE, + plugins=[], + docker=False, + docker_infra=False, + ) + assert config.slug == "myprojectname" + + def test_slug_handles_digit_prefix(self) -> None: + """Verify slug adds underscore prefix for digit-starting names.""" + config = ProjectConfig( + name="123app", + framework=Framework.LITESTAR, + database=Database.NONE, + memory_store=MemoryStore.NONE, + plugins=[], + docker=False, + docker_infra=False, + ) + assert config.slug == "_123app" + + def test_slug_handles_consecutive_separators(self) -> None: + """Verify slug collapses consecutive hyphens/spaces to single underscore.""" + config = ProjectConfig( + name="my--project name", + framework=Framework.LITESTAR, + database=Database.NONE, + memory_store=MemoryStore.NONE, + plugins=[], + docker=False, + docker_infra=False, + ) + assert config.slug == "my_project_name" + class TestDatabaseConfig: """Tests for DatabaseConfig model.""" diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 67985cf..972c591 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1,6 +1,9 @@ # ruff: noqa: PLR6301 +import importlib from pathlib import Path +from pytest_mock import MockerFixture + from src.Litestar.Plugins.AdvancedAlchemy import AdvancedAlchemyPlugin from src.Litestar.Plugins.LitestarSAQ import LitestarSAQPlugin from src.models import Database, Framework, MemoryStore, ProjectConfig @@ -163,6 +166,44 @@ def test_discover_nonexistent_framework(self) -> None: plugins = discover_plugins("NonExistent") assert plugins == [] + def test_discover_plugins_handles_import_error(self, mocker: MockerFixture) -> None: + """Verify discover_plugins skips plugins that raise ImportError.""" + original_import = importlib.import_module + + def failing_import(name: str) -> object: + if "AdvancedAlchemy" in name: + msg = "Simulated import failure" + raise ImportError(msg) + return original_import(name) + + mocker.patch("importlib.import_module", side_effect=failing_import) + + plugins = discover_plugins("Litestar") + + # Should have fewer plugins since AdvancedAlchemy failed to import + ids = [p.id for p in plugins] + assert "advanced_alchemy" not in ids + # Other plugins should still be discovered + assert len(plugins) >= 1 + + def test_discover_plugins_handles_attribute_error(self, mocker: MockerFixture) -> None: + """Verify discover_plugins skips plugins that raise AttributeError.""" + original_import = importlib.import_module + + def failing_import(name: str) -> object: + if "LitestarSAQ" in name: + msg = "Simulated attribute error" + raise AttributeError(msg) + return original_import(name) + + mocker.patch("importlib.import_module", side_effect=failing_import) + + plugins = discover_plugins("Litestar") + + ids = [p.id for p in plugins] + assert "litestar_saq" not in ids + assert len(plugins) >= 1 + class TestAdvancedAlchemyPlugin: """Tests for AdvancedAlchemyPlugin.""" diff --git a/tests/test_project_generator.py b/tests/test_project_generator.py index edb5873..994b72b 100644 --- a/tests/test_project_generator.py +++ b/tests/test_project_generator.py @@ -3,6 +3,7 @@ from pathlib import Path +import pytest from pytest_mock import MockerFixture from src.generator import ProjectGenerator @@ -91,3 +92,30 @@ def test_post_generate_when_no_framework_generator(self, tmp_path: Path) -> None # Should not raise generator.post_generate() + + def test_generate_unsupported_framework_raises(self, tmp_path: Path) -> None: + """Verify generate raises NotImplementedError for unsupported framework.""" + config = ProjectConfig( + name="Test", + framework=Framework.LITESTAR, + database=Database.NONE, + memory_store=MemoryStore.NONE, + plugins=[], + docker=False, + docker_infra=False, + ) + + generator = ProjectGenerator(config, tmp_path) + # Monkeypatch the framework to a value that's not handled + generator.config = ProjectConfig( + name="Test", + framework="UnsupportedFramework", # type: ignore[arg-type] + database=Database.NONE, + memory_store=MemoryStore.NONE, + plugins=[], + docker=False, + docker_infra=False, + ) + + with pytest.raises(NotImplementedError, match="not yet supported"): + generator.generate() diff --git a/tests/test_utils.py b/tests/test_utils.py index 71957c3..b010551 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -4,10 +4,8 @@ from pathlib import Path from src.utils import ( - create_directory, get_package_dir, get_template_env, - render_template, slugify, validate_project_name, write_file, @@ -122,6 +120,34 @@ def test_mixed_case_spaces_hyphens(self) -> None: assert slugify("My-Cool Project") == "my_cool_project" assert slugify("The-Great App Name") == "the_great_app_name" + def test_unicode_characters(self) -> None: + """Verify slugify strips unicode characters.""" + assert slugify("café") == "caf" + assert slugify("naïve") == "nave" + + def test_consecutive_hyphens(self) -> None: + """Verify slugify collapses consecutive hyphens to single underscore.""" + assert slugify("my--project") == "my_project" + assert slugify("a---b") == "a_b" + + def test_consecutive_spaces(self) -> None: + """Verify slugify collapses consecutive spaces to single underscore.""" + assert slugify("my project") == "my_project" + + def test_mixed_consecutive_separators(self) -> None: + """Verify slugify collapses mixed hyphens/spaces to single underscore.""" + assert slugify("my - project") == "my_project" + assert slugify("a - - b") == "a_b" + + def test_leading_trailing_separators(self) -> None: + """Verify slugify handles leading/trailing hyphens and spaces.""" + assert slugify("-project-") == "_project_" + assert slugify(" project ") == "_project_" + + def test_underscores_preserved(self) -> None: + """Verify slugify preserves existing underscores.""" + assert slugify("my_project") == "my_project" + class TestValidateProjectName: """Tests for validate_project_name function.""" @@ -158,30 +184,17 @@ def test_single_letter(self) -> None: assert validate_project_name("a") is None assert validate_project_name("Z") is None + def test_max_length_boundary(self) -> None: + """Verify validate_project_name at exact 50-char boundary.""" + assert validate_project_name("x" * 49) is None + assert validate_project_name("x" * 50) is None + error = validate_project_name("x" * 51) + assert error is not None + assert "50" in error -class TestCreateDirectory: - """Tests for create_directory function.""" - - def test_creates_directory(self, tmp_path: Path) -> None: - """Verify create_directory creates a directory.""" - new_dir = tmp_path / "test_dir" - create_directory(new_dir) - assert new_dir.exists() - assert new_dir.is_dir() - - def test_creates_nested_directories(self, tmp_path: Path) -> None: - """Verify create_directory creates nested directories.""" - new_dir = tmp_path / "parent" / "child" / "grandchild" - create_directory(new_dir) - assert new_dir.exists() - assert new_dir.is_dir() - - def test_idempotent(self, tmp_path: Path) -> None: - """Verify create_directory is idempotent.""" - new_dir = tmp_path / "test_dir" - create_directory(new_dir) - create_directory(new_dir) # Should not raise - assert new_dir.exists() + def test_numeric_only_name(self) -> None: + """Verify validate_project_name accepts numeric-only names (slugified to _123).""" + assert validate_project_name("123") is None class TestWriteFile: @@ -216,37 +229,3 @@ def test_utf8_encoding(self, tmp_path: Path) -> None: content = "Hello 世界 🌍" write_file(file_path, content) assert file_path.read_text(encoding="utf-8") == content - - -class TestRenderTemplate: - """Tests for render_template function.""" - - def test_renders_template(self, tmp_path: Path) -> None: - """Verify render_template renders a template.""" - template_file = tmp_path / "test.txt" - template_file.write_text("Hello {{ name }}!") - - env = get_template_env(tmp_path) - result = render_template(env, "test.txt", {"name": "World"}) - assert result == "Hello World!" - - def test_renders_with_multiple_variables(self, tmp_path: Path) -> None: - """Verify render_template handles multiple variables.""" - template_file = tmp_path / "test.txt" - template_file.write_text("{{ greeting }} {{ name }}, age {{ age }}") - - env = get_template_env(tmp_path) - result = render_template(env, "test.txt", {"greeting": "Hello", "name": "Alice", "age": 30}) - assert result == "Hello Alice, age 30" - - def test_renders_with_conditionals(self, tmp_path: Path) -> None: - """Verify render_template handles conditionals.""" - template_file = tmp_path / "test.txt" - template_file.write_text("{% if show %}Visible{% endif %}") - - env = get_template_env(tmp_path) - result_true = render_template(env, "test.txt", {"show": True}) - result_false = render_template(env, "test.txt", {"show": False}) - - assert result_true == "Visible" - assert not result_false diff --git a/tests/test_vite_lifecycle.py b/tests/test_vite_lifecycle.py index d29135a..fe79023 100644 --- a/tests/test_vite_lifecycle.py +++ b/tests/test_vite_lifecycle.py @@ -5,6 +5,7 @@ from pytest_mock import MockerFixture from src.Litestar.Plugins.LitestarVite import LitestarVitePlugin +from src.models import ProjectConfig @pytest.fixture @@ -71,7 +72,7 @@ def test_post_generate(tmp_path: Path, mocker: MockerFixture) -> None: mock_run = mocker.patch("subprocess.run") # Mock ProjectConfig - config = mocker.Mock() + config = mocker.Mock(spec=ProjectConfig) # Setup src/backend/app.py backend_dir = tmp_path / "src" / "backend"