diff --git a/README.md b/README.md index 6a5bc0d..2e93773 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ It's repetitive. It's inconsistent. And you haven't written a single line of *re | Feature | What it does | |---|---| -| **Intent-based templates** | Backend API (FastAPI / Flask / Django), CLI Application (Typer / Click / Argparse), Automation Tool | +| **Intent-based templates** | Backend API (FastAPI / Flask / Django), CLI Application (Typer / Click / Argparse), Automation Tool, AI Chatbot | | **Extras system** | Opt-in ruff, pytest, Docker, GitHub Actions — installed and wired automatically | | **Dependency installation** | `uv add` runs automatically with the right packages for your choices | | **Git + uv** | Optionally runs `git init`, `uv init`, and `uv venv` | @@ -91,8 +91,9 @@ Spawn rejects names with spaces or special characters, and tells you immediately 1 Backend API 2 CLI Application 3 Automation Tool + 4 AI Chatbot -Choose Template [1-3]: 1 +Choose Template [1-4]: 1 ``` **Step 3 — Additional prompts** *(template-dependent)* @@ -273,6 +274,31 @@ uv run python -m src.main --- +### `[4]` AI Chatbot + +Best for: customer support bots, study assistants, conversational AI tools. + +``` +my-chatbot/ +├── src/ +│ ├── chatbot/ +│ ├── providers/ +│ ├── prompts/ +│ ├── utils/ +│ └── main.py +├── tests/ +├── .env.example +└── README.md +``` + +```bash +cd my-chatbot +# Add API_KEY to .env +uv run python -m src.main +``` + +--- + ## Other Commands ### `spawn doctor` @@ -315,7 +341,7 @@ spawn doctor ./path/to/project ```bash spawn version -# → Spawn v0.5.0 +# → Spawn v0.6.0 ``` ### Publish to GitHub @@ -351,7 +377,7 @@ All tests should pass. If they don't, please [open an issue](https://github.com/ - [x] **Dependency installation** — `uv add` runs automatically after generation (v0.3.0) - [x] **CLI Application intent** — Typer, Click, Argparse with Utility/Interactive sub-types (v0.4.0) - [x] **Automation Tool intent** — workflow-based automation with logging, tasks, and integrations (v0.5.0) -- [ ] **AI Chatbot intent** — LLM-integrated chat app scaffold (v0.6.0) +- [x] **AI Chatbot intent** — PydanticAI and OpenAI SDK with provider abstraction (v0.6.0) - [ ] **AI Agent intent** — tool-calling agent scaffold (v0.7.0) - [ ] **RAG System intent** — retrieval-augmented generation scaffold (v0.8.0) - [ ] **Data Project intent** — analysis, dashboard, ETL, ML sub-options (v0.9.0) diff --git a/docs/architecture.md b/docs/architecture.md index 3b8e8eb..0dc11ab 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -23,7 +23,7 @@ Entry point: `spawn.cli.app:main` (defined in `pyproject.toml` as `[project.scri ``` src/spawn/ -├── __init__.py # __version__ via importlib.metadata, fallback "0.5.0" +├── __init__.py # __version__ via importlib.metadata, fallback "0.6.0" ├── cli/ │ ├── app.py # Typer app: create, version, doctor commands │ └── prompts.py # Interactive prompts; derives menu from registry @@ -50,6 +50,9 @@ src/spawn/ │ ├── automation/ │ │ ├── __init__.py # AutomationTemplate │ │ └── content.py # Workflow, task, logger, test, README content strings +│ ├── chatbot/ +│ │ ├── __init__.py # ChatbotTemplate (branches on framework) +│ │ └── content.py # LLM provider, chat, prompt, test, README strings │ ├── python_script/ # Reserved — not in active menu │ ├── data_science/ # Reserved — not in active menu │ └── ml_project/ # Reserved — not in active menu @@ -137,6 +140,7 @@ Each template lives in its own subdirectory with an `__init__.py` (class) and `c | `backend-api` | `BackendAPITemplate` | fastapi, flask, django | ruff, pytest, docker, github-actions | | `cli` | `CLITemplate` | typer, click, argparse | ruff, pytest, github-actions | | `automation` | `AutomationTemplate` | none | ruff, pytest, github-actions | +| `chatbot` | `ChatbotTemplate` | pydantic-ai, openai-sdk | ruff, pytest, github-actions | `get_template(slug)` returns a default-constructed instance. `instantiate_template(config)` forwards `framework`, `extras`, and `cli_type` from `ProjectConfig` to templates whose constructors accept them, using signature introspection. diff --git a/docs/changelog.md b/docs/changelog.md index 863a6d7..6c6fc9b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,6 +3,41 @@ All notable changes to Spawn are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## v0.6.0 — 2026 + +### New Features + +- **AI Chatbot intent** — generates a fully runnable conversational AI project + with runtime memory, centralized prompt management, and provider abstraction +- **3 frameworks**: PydanticAI, OpenAI SDK, LiteLLM +- **6 providers**: OpenAI, Anthropic, Gemini, OpenRouter, Ollama, Groq +- **16 supported combinations** — each generates correct dependencies, + provider-specific env vars, and working llm.py out of the box +- **Runtime memory** — `src/memory/history.py` maintains conversation + context across turns within a session; no database required +- **Plain-text prompt system** — `src/prompts/system.txt` is editable + without touching Python code; loaded dynamically at runtime +- **Rich extra** — opt-in `rich` terminal UI with colored panels and + styled input/output +- **Provider-specific env vars** — generated `.env.example` uses the + correct key name for each provider (OPENAI_API_KEY, ANTHROPIC_API_KEY, + GOOGLE_API_KEY, OPENROUTER_API_KEY, OLLAMA_BASE_URL) + +### Bug Fixes + +- Fixed `result.data` → `result.output` in PydanticAI provider + (AgentRunResult attribute name in current pydantic-ai) +- Fixed `setdefault("OPENAI_API_KEY")` pattern that silently broke + non-OpenAI providers; api_key is now passed directly to run_sync() + +### Internal + +- `ProjectConfig` gains `provider: str | None = None` field +- `TemplateMetadata` gains `available_providers: list[str]` field +- `instantiate_template()` forwards `provider` to template constructors +- Registry: chatbot updated to 3 frameworks, 5 providers, 4 extras +- Version bumped to 0.6.0 + ## v0.5.0 — 2026 ### New Features diff --git a/docs/commands.md b/docs/commands.md index 4e46536..bd19e5a 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -19,7 +19,7 @@ Creates a new project directory from a template, writes starter files, installs | Step | Prompt | When shown | |---|---|---| | 1 | `Project Name` | Always | -| 2 | Template list → `Choose Template [1-3]` | Always | +| 2 | Template list → `Choose Template [1-4]` | Always | | 3 | CLI Type list → `Choose CLI Type [1-2]` | Only for CLI Application | | 4 | Framework list → `Choose Framework [1-N]` | Only for templates with frameworks | | 5 | Extras list → `Extras` | Only for templates with extras | @@ -49,6 +49,7 @@ Templates are displayed as a numbered list. The current registry order: | `1` | `backend-api` | Backend API — production-ready FastAPI, Flask, or Django | | `2` | `cli` | CLI Application — Typer, Click, or Argparse with Utility or Interactive type | | `3` | `automation` | Automation Tool — workflow-based automation with logging and tasks | +| `4` | `chatbot` | AI Chatbot — PydanticAI or OpenAI SDK with provider abstraction | **Invalid input error (exact):** ``` @@ -361,6 +362,30 @@ uv run python -m src.main --- +### AI Chatbot intent + +Selecting AI Chatbot triggers a framework prompt then extras. + +#### Framework selection + +``` + 1 pydantic-ai + 2 openai-sdk + +Choose Framework [1-2]: +``` + +| Input | Framework | Key dependency | +|---|---|---| +| `1` (default) | PydanticAI | `pydantic-ai` | +| `2` | OpenAI SDK | `openai` | + +Both variants install `python-dotenv` and use the same project structure. + +Provider switching requires changes only in `src/providers/llm.py` and `.env`. + +--- + ### `.spawn/meta.json` Every generated project receives a `.spawn/meta.json` file: @@ -369,7 +394,7 @@ Every generated project receives a `.spawn/meta.json` file: { "intent": "backend-api", "framework": "fastapi", - "spawn_version": "0.5.0" + "spawn_version": "0.6.0" } ``` @@ -443,7 +468,7 @@ Prints the installed package version. **Output (exact):** ``` -Spawn v0.5.0 +Spawn v0.6.0 ``` --- diff --git a/docs/getting-started.md b/docs/getting-started.md index 9e6379a..f57fb61 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -31,8 +31,9 @@ $ spawn create 1 Backend API 2 CLI Application 3 Automation Tool + 4 AI Chatbot -Choose Template [1-3]: 2 +Choose Template [1-4]: 2 1 utility 2 interactive @@ -81,8 +82,9 @@ $ spawn create 1 Backend API 2 CLI Application 3 Automation Tool + 4 AI Chatbot -Choose Template [1-3]: 1 +Choose Template [1-4]: 1 1 fastapi 2 flask @@ -165,7 +167,7 @@ spawn version ``` ``` -Spawn v0.5.0 +Spawn v0.6.0 ``` ```bash diff --git a/pyproject.toml b/pyproject.toml index 1b14482..88a8f47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "spawn" -version = "0.5.0" +version = "0.6.0" description = "Spawn a ready-to-use Python project in seconds" readme = "README.md" requires-python = ">=3.12" diff --git a/src/spawn/__init__.py b/src/spawn/__init__.py index 864eaa1..30fcb35 100644 --- a/src/spawn/__init__.py +++ b/src/spawn/__init__.py @@ -3,4 +3,4 @@ try: __version__ = version("spawn") except PackageNotFoundError: - __version__ = "0.5.0" + __version__ = "0.6.0" diff --git a/src/spawn/cli/prompts.py b/src/spawn/cli/prompts.py index 9fed0fa..6f6b570 100644 --- a/src/spawn/cli/prompts.py +++ b/src/spawn/cli/prompts.py @@ -5,6 +5,7 @@ from spawn.utils.console import console from spawn.core.models import ProjectConfig from spawn.core.registry import list_templates, get_metadata +from spawn.templates.chatbot import get_supported_providers from spawn.utils.validators import validate_project_name from spawn.core.exceptions import SpawnError @@ -69,6 +70,7 @@ def get_project_config() -> ProjectConfig: # --- Framework selection --- selected_framework: str | None = None selected_cli_type: str | None = None + selected_provider: str | None = None meta = get_metadata(template) # --- CLI type selection --- @@ -130,6 +132,43 @@ def get_project_config() -> ProjectConfig: selected_framework = framework_map[fw_choice] + # --- Provider selection --- + if meta and meta.available_providers and selected_framework: + provider_options = ( + get_supported_providers(selected_framework) + if selected_framework + else meta.available_providers + ) + provider_choice_map = { + str(i): p for i, p in enumerate(provider_options, start=1) + } + + _print_list(provider_options) + + valid_prov_range = len(provider_options) + prov_choice = typer.prompt( + typer.style( + f"Choose Provider [1-{valid_prov_range}]", + fg=typer.colors.CYAN, + ), + default="1", + ) + + while prov_choice not in provider_choice_map: + typer.secho( + "Invalid choice. Please select a valid number.", + fg=typer.colors.RED, + ) + prov_choice = typer.prompt( + typer.style( + f"Choose Provider [1-{valid_prov_range}]", + fg=typer.colors.CYAN, + ), + default="1", + ) + + selected_provider = provider_choice_map[prov_choice] + # --- Extras selection --- selected_extras: list[str] = [] @@ -175,4 +214,5 @@ def get_project_config() -> ProjectConfig: framework=selected_framework, extras=selected_extras, cli_type=selected_cli_type, + provider=selected_provider, ) diff --git a/src/spawn/core/models.py b/src/spawn/core/models.py index edec7e4..1dba0b9 100644 --- a/src/spawn/core/models.py +++ b/src/spawn/core/models.py @@ -9,3 +9,4 @@ class ProjectConfig: framework: str | None = None extras: list[str] = field(default_factory=list) cli_type: str | None = None + provider: str | None = None diff --git a/src/spawn/core/registry.py b/src/spawn/core/registry.py index 96f9cf7..6f4c14b 100644 --- a/src/spawn/core/registry.py +++ b/src/spawn/core/registry.py @@ -9,6 +9,7 @@ from spawn.templates.backend_api import BackendAPITemplate from spawn.templates.cli_application import CLITemplate from spawn.templates.automation import AutomationTemplate +from spawn.templates.chatbot import ChatbotTemplate from spawn.templates.base import BaseTemplate @@ -21,6 +22,7 @@ class TemplateMetadata: available_frameworks: list[str] = field(default_factory=list) available_extras: list[str] = field(default_factory=list) available_cli_types: list[str] = field(default_factory=list) + available_providers: list[str] = field(default_factory=list) # Slugs that existed in previous versions but have been superseded. @@ -53,6 +55,15 @@ class TemplateMetadata: template_class=AutomationTemplate, available_extras=["ruff", "pytest", "github-actions"], ), + "chatbot": TemplateMetadata( + slug="chatbot", + display_name="AI Chatbot", + description="Conversational AI with PydanticAI, OpenAI SDK, or LiteLLM", + template_class=ChatbotTemplate, + available_frameworks=["pydantic-ai", "openai-sdk", "litellm"], + available_providers=["openai", "anthropic", "gemini", "openrouter", "ollama", "groq"], + available_extras=["ruff", "pytest", "rich", "github-actions"], + ), } @@ -93,6 +104,8 @@ def instantiate_template(config: ProjectConfig) -> BaseTemplate | None: kwargs["extras"] = config.extras if "cli_type" in params: kwargs["cli_type"] = config.cli_type + if "provider" in params: + kwargs["provider"] = config.provider return cls(**kwargs) diff --git a/src/spawn/generators/project_generator.py b/src/spawn/generators/project_generator.py index 04a08a9..861a520 100644 --- a/src/spawn/generators/project_generator.py +++ b/src/spawn/generators/project_generator.py @@ -76,6 +76,7 @@ def generate(self, config: ProjectConfig) -> Path: { "intent": config.template, "framework": config.framework, + "provider": config.provider, "spawn_version": __version__, }, indent=2, diff --git a/src/spawn/templates/chatbot/__init__.py b/src/spawn/templates/chatbot/__init__.py new file mode 100644 index 0000000..fabbb8f --- /dev/null +++ b/src/spawn/templates/chatbot/__init__.py @@ -0,0 +1,238 @@ +from pathlib import Path + +from spawn.templates.base import BaseTemplate +from spawn.templates.chatbot.content import ( + INIT_CONTENT, + CHAT_CONTENT, + MEMORY_HISTORY_CONTENT_BASE, + MEMORY_HISTORY_CONTENT_PYDANTIC, + SYSTEM_PROMPT_TXT_CONTENT, + SETTINGS_CONTENT, + CONFTEST_CONTENT, + MAIN_CONTENT_NO_RICH, + MAIN_CONTENT_RICH, + TEST_CONTENT, + GITHUB_ACTIONS_CI_BASE, + GITHUB_ACTIONS_CI_RUFF_STEP, + GITHUB_ACTIONS_CI_PYTEST_STEP, + PYDANTIC_AI_OPENAI_LLM_CONTENT, + PYDANTIC_AI_ANTHROPIC_LLM_CONTENT, + PYDANTIC_AI_GEMINI_LLM_CONTENT, + PYDANTIC_AI_OPENROUTER_LLM_CONTENT, + PYDANTIC_AI_OLLAMA_LLM_CONTENT, + PYDANTIC_AI_GROQ_LLM_CONTENT, + OPENAI_SDK_OPENAI_LLM_CONTENT, + OPENAI_SDK_OPENROUTER_LLM_CONTENT, + OPENAI_SDK_GEMINI_LLM_CONTENT, + OPENAI_SDK_GROQ_LLM_CONTENT, + LITELLM_OPENAI_LLM_CONTENT, + LITELLM_ANTHROPIC_LLM_CONTENT, + LITELLM_GEMINI_LLM_CONTENT, + LITELLM_OPENROUTER_LLM_CONTENT, + LITELLM_OLLAMA_LLM_CONTENT, + LITELLM_GROQ_LLM_CONTENT, + ENV_OPENAI, + ENV_ANTHROPIC, + ENV_GEMINI, + ENV_OPENROUTER, + ENV_OLLAMA, + ENV_LITELLM_OLLAMA, + ENV_GROQ, + ENV_PYDANTIC_OPENAI, + ENV_PYDANTIC_ANTHROPIC, + ENV_PYDANTIC_GEMINI, + ENV_PYDANTIC_OPENROUTER, + ENV_PYDANTIC_OLLAMA, + ENV_PYDANTIC_GROQ, + ENV_LITELLM_GROQ, + make_readme, +) + +CHATBOT_FOLDERS = [ + "src/chatbot", + "src/providers", + "src/prompts", + "src/memory", + "src/config", + "tests", +] + +# Maps (framework, provider) → llm content +_LLM_MAP: dict[tuple[str, str], str] = { + ("pydantic-ai", "openai"): PYDANTIC_AI_OPENAI_LLM_CONTENT, + ("pydantic-ai", "anthropic"): PYDANTIC_AI_ANTHROPIC_LLM_CONTENT, + ("pydantic-ai", "gemini"): PYDANTIC_AI_GEMINI_LLM_CONTENT, + ("pydantic-ai", "openrouter"): PYDANTIC_AI_OPENROUTER_LLM_CONTENT, + ("pydantic-ai", "ollama"): PYDANTIC_AI_OLLAMA_LLM_CONTENT, + ("pydantic-ai", "groq"): PYDANTIC_AI_GROQ_LLM_CONTENT, + ("openai-sdk", "openai"): OPENAI_SDK_OPENAI_LLM_CONTENT, + ("openai-sdk", "openrouter"): OPENAI_SDK_OPENROUTER_LLM_CONTENT, + ("openai-sdk", "gemini"): OPENAI_SDK_GEMINI_LLM_CONTENT, + ("openai-sdk", "groq"): OPENAI_SDK_GROQ_LLM_CONTENT, + ("litellm", "openai"): LITELLM_OPENAI_LLM_CONTENT, + ("litellm", "anthropic"): LITELLM_ANTHROPIC_LLM_CONTENT, + ("litellm", "gemini"): LITELLM_GEMINI_LLM_CONTENT, + ("litellm", "openrouter"): LITELLM_OPENROUTER_LLM_CONTENT, + ("litellm", "ollama"): LITELLM_OLLAMA_LLM_CONTENT, + ("litellm", "groq"): LITELLM_GROQ_LLM_CONTENT, +} + +# Maps provider → env example (pydantic-ai uses prefixed MODEL format) +_ENV_MAP_PYDANTIC: dict[str, str] = { + "openai": ENV_PYDANTIC_OPENAI, + "anthropic": ENV_PYDANTIC_ANTHROPIC, + "gemini": ENV_PYDANTIC_GEMINI, + "openrouter": ENV_PYDANTIC_OPENROUTER, + "ollama": ENV_PYDANTIC_OLLAMA, + "groq": ENV_PYDANTIC_GROQ, +} + +_ENV_MAP_GENERIC: dict[str, str] = { + "openai": ENV_OPENAI, + "anthropic": ENV_ANTHROPIC, + "gemini": ENV_GEMINI, + "openrouter": ENV_OPENROUTER, + "ollama": ENV_OLLAMA, + "groq": ENV_GROQ, +} + +# litellm+ollama uses OLLAMA_API_BASE (litellm convention) instead of OLLAMA_BASE_URL +_ENV_MAP_LITELLM: dict[str, str] = { + "openai": ENV_OPENAI, + "anthropic": ENV_ANTHROPIC, + "gemini": ENV_GEMINI, + "openrouter": ENV_OPENROUTER, + "ollama": ENV_LITELLM_OLLAMA, + "groq": ENV_LITELLM_GROQ, +} + + +def _resolve_llm(framework: str, provider: str) -> str: + return _LLM_MAP.get((framework, provider), PYDANTIC_AI_OPENAI_LLM_CONTENT) + + +def get_supported_providers(framework: str) -> list[str]: + """Return providers supported for a given framework, in menu order.""" + order = ["openai", "anthropic", "gemini", "openrouter", "ollama", "groq"] + supported = {provider for (fw, provider) in _LLM_MAP if fw == framework} + return [p for p in order if p in supported] + + +def _resolve_env(framework: str, provider: str) -> str: + if framework == "pydantic-ai": + return _ENV_MAP_PYDANTIC.get(provider, ENV_PYDANTIC_OPENAI) + if framework == "litellm": + return _ENV_MAP_LITELLM.get(provider, ENV_OPENAI) + return _ENV_MAP_GENERIC.get(provider, ENV_OPENAI) + + +def _build_files(main_content: str, llm_content: str, env_content: str, memory_content: str) -> list: + return [ + ("src/__init__.py", INIT_CONTENT), + ("src/chatbot/__init__.py", INIT_CONTENT), + ("src/chatbot/chat.py", CHAT_CONTENT), + ("src/providers/__init__.py", INIT_CONTENT), + ("src/providers/llm.py", llm_content), + ("src/prompts/__init__.py", INIT_CONTENT), + ("src/prompts/system.txt", SYSTEM_PROMPT_TXT_CONTENT), + ("src/memory/__init__.py", INIT_CONTENT), + ("src/memory/history.py", memory_content), + ("src/config/__init__.py", INIT_CONTENT), + ("src/config/settings.py", SETTINGS_CONTENT), + ("src/main.py", main_content), + ("tests/__init__.py", INIT_CONTENT), + ("tests/conftest.py", CONFTEST_CONTENT), + ("tests/test_chatbot.py", TEST_CONTENT), + (".env.example", env_content), + ] + + +class ChatbotTemplate(BaseTemplate): + def __init__( + self, + framework: str | None = None, + provider: str | None = None, + extras: list[str] | None = None, + ) -> None: + self.framework = framework or "pydantic-ai" + self.provider = provider or "openai" + self.extras = extras or [] + + llm_content = _resolve_llm(self.framework, self.provider) + env_content = _resolve_env(self.framework, self.provider) + main_content = MAIN_CONTENT_RICH if "rich" in self.extras else MAIN_CONTENT_NO_RICH + memory_content = ( + MEMORY_HISTORY_CONTENT_PYDANTIC + if self.framework == "pydantic-ai" + else MEMORY_HISTORY_CONTENT_BASE + ) + + super().__init__( + name="AI Chatbot", + folders=list(CHATBOT_FOLDERS), + starter_files=_build_files(main_content, llm_content, env_content, memory_content), + next_steps=[ + "cd {project_name}", + "Rename .env.example to .env and fill in your API key", + "uv run python -m src.main", + ], + ) + + def get_readme_content(self, context: dict) -> str | None: + raw = make_readme(self.framework, self.provider) + return raw.format_map(context) + + def get_dependencies(self) -> list[str]: + dep_map: dict[tuple[str, str], list[str]] = { + ("pydantic-ai", "openai"): ["pydantic-ai", "python-dotenv"], + ("pydantic-ai", "anthropic"): ["pydantic-ai", "python-dotenv"], + ("pydantic-ai", "gemini"): ["pydantic-ai", "python-dotenv"], + ("pydantic-ai", "openrouter"): ["pydantic-ai", "python-dotenv"], + ("pydantic-ai", "ollama"): ["pydantic-ai", "python-dotenv"], + ("pydantic-ai", "groq"): ["pydantic-ai[groq]", "python-dotenv"], + ("openai-sdk", "openai"): ["openai", "python-dotenv"], + ("openai-sdk", "openrouter"): ["openai", "python-dotenv"], + ("openai-sdk", "gemini"): ["openai", "python-dotenv"], + ("openai-sdk", "groq"): ["openai", "python-dotenv"], + ("litellm", "openai"): ["litellm", "python-dotenv"], + ("litellm", "anthropic"): ["litellm", "python-dotenv"], + ("litellm", "gemini"): ["litellm", "python-dotenv"], + ("litellm", "openrouter"): ["litellm", "python-dotenv"], + ("litellm", "ollama"): ["litellm", "python-dotenv"], + ("litellm", "groq"): ["litellm", "python-dotenv"], + } + + base = list(dep_map.get((self.framework, self.provider), ["pydantic-ai", "openai", "python-dotenv"])) + + if "pytest" in self.extras: + base.append("pytest") + if "ruff" in self.extras: + base.append("ruff") + if "rich" in self.extras: + base.append("rich") + + return base + + def post_install(self, project_path: Path) -> None: + pyproject = project_path / "pyproject.toml" + current = pyproject.read_text(encoding="utf-8") + additions = "" + + if "pytest" in self.extras: + additions += "\n[tool.pytest.ini_options]\ntestpaths = [\"tests\"]\n" + + if "ruff" in self.extras: + additions += "\n[tool.ruff]\nline-length = 88\n" + + if additions: + pyproject.write_text(current + additions, encoding="utf-8") + + if "github-actions" in self.extras: + workflows_path = project_path / ".github" / "workflows" + workflows_path.mkdir(parents=True, exist_ok=True) + ci = GITHUB_ACTIONS_CI_BASE + if "ruff" in self.extras: + ci += GITHUB_ACTIONS_CI_RUFF_STEP + if "pytest" in self.extras: + ci += GITHUB_ACTIONS_CI_PYTEST_STEP + (workflows_path / "ci.yml").write_text(ci, encoding="utf-8") \ No newline at end of file diff --git a/src/spawn/templates/chatbot/content.py b/src/spawn/templates/chatbot/content.py new file mode 100644 index 0000000..386b4b5 --- /dev/null +++ b/src/spawn/templates/chatbot/content.py @@ -0,0 +1,647 @@ +INIT_CONTENT = "" + +# ─── Shared content ─────────────────────────────────────────────────────── + +CHAT_CONTENT = """\ +from src.providers.llm import get_llm_response +from src.memory.history import append_user, append_assistant, get_history +from src.config.settings import get_system_prompt + + +def get_response(user_input: str) -> str: + append_user(user_input) + history = get_history() + system_prompt = get_system_prompt() + response = get_llm_response(history, system_prompt) + append_assistant(response) + return response +""" + +MEMORY_HISTORY_CONTENT_BASE = """\ +_history: list[dict] = [] + + +def append_user(content: str) -> None: + _history.append({{"role": "user", "content": content}}) + + +def append_assistant(content: str) -> None: + _history.append({{"role": "assistant", "content": content}}) + + +def get_history() -> list[dict]: + return list(_history) + + +def clear() -> None: + _history.clear() +""" + +MEMORY_HISTORY_CONTENT_PYDANTIC = """\ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pydantic_ai.messages import ModelMessage + +_history: list[dict] = [] +_pai_history: list[ModelMessage] = [] + + +def append_user(content: str) -> None: + _history.append({{"role": "user", "content": content}}) + + +def append_assistant(content: str) -> None: + _history.append({{"role": "assistant", "content": content}}) + + +def get_history() -> list[dict]: + return list(_history) + + +def get_pai_history() -> list[ModelMessage]: + return list(_pai_history) + + +def append_pai_messages(messages: list[ModelMessage]) -> None: + _pai_history.extend(messages) + + +def clear() -> None: + _history.clear() + _pai_history.clear() +""" + +SYSTEM_PROMPT_TXT_CONTENT = """\ +You are a helpful AI assistant. +Answer questions clearly and concisely. +""" + +SETTINGS_CONTENT = """\ +from pathlib import Path + +from dotenv import load_dotenv + + +def load_env() -> None: + load_dotenv() + + +def get_system_prompt() -> str: + prompt_path = Path(__file__).parent.parent / "prompts" / "system.txt" + return prompt_path.read_text(encoding="utf-8").strip() +""" + +# ─── PydanticAI ─────────────────────────────────────────────────────────── + +PYDANTIC_AI_OPENAI_LLM_CONTENT = """\ +import os + +from pydantic_ai import Agent + +from src.memory.history import get_pai_history, append_pai_messages + + +def get_llm_response(messages: list[dict], system_prompt: str) -> str: + model = os.getenv("MODEL", "openai:gpt-4o-mini") + user_messages = [m["content"] for m in messages if m["role"] == "user"] + prompt = user_messages[-1] if user_messages else "" + agent = Agent(model, system_prompt=system_prompt) + result = agent.run_sync(prompt, message_history=get_pai_history()) + append_pai_messages(result.new_messages()) + return result.output +""" + +PYDANTIC_AI_ANTHROPIC_LLM_CONTENT = """\ +import os + +from pydantic_ai import Agent + +from src.memory.history import get_pai_history, append_pai_messages + + +def get_llm_response(messages: list[dict], system_prompt: str) -> str: + model = os.getenv("MODEL", "anthropic:claude-3-5-haiku-latest") + user_messages = [m["content"] for m in messages if m["role"] == "user"] + prompt = user_messages[-1] if user_messages else "" + agent = Agent(model, system_prompt=system_prompt) + result = agent.run_sync(prompt, message_history=get_pai_history()) + append_pai_messages(result.new_messages()) + return result.output +""" + +PYDANTIC_AI_GEMINI_LLM_CONTENT = """\ +import os + +from pydantic_ai import Agent + +from src.memory.history import get_pai_history, append_pai_messages + + +def get_llm_response(messages: list[dict], system_prompt: str) -> str: + model = os.getenv("MODEL", "google:gemini-2.0-flash") + user_messages = [m["content"] for m in messages if m["role"] == "user"] + prompt = user_messages[-1] if user_messages else "" + agent = Agent(model, system_prompt=system_prompt) + result = agent.run_sync(prompt, message_history=get_pai_history()) + append_pai_messages(result.new_messages()) + return result.output +""" + +PYDANTIC_AI_OPENROUTER_LLM_CONTENT = """\ +import os + +from pydantic_ai import Agent +from pydantic_ai.models.openai import OpenAIChatModel +from pydantic_ai.providers.openai import OpenAIProvider + +from src.memory.history import get_pai_history, append_pai_messages + + +def get_llm_response(messages: list[dict], system_prompt: str) -> str: + model_name = os.getenv("MODEL", "openai/gpt-4o-mini") + api_key = os.getenv("OPENROUTER_API_KEY", "") + user_messages = [m["content"] for m in messages if m["role"] == "user"] + prompt = user_messages[-1] if user_messages else "" + provider = OpenAIProvider( + api_key=api_key, + base_url="https://openrouter.ai/api/v1", + ) + model = OpenAIChatModel(model_name, provider=provider) + agent = Agent(model, system_prompt=system_prompt) + result = agent.run_sync(prompt, message_history=get_pai_history()) + append_pai_messages(result.new_messages()) + return result.output +""" + +PYDANTIC_AI_OLLAMA_LLM_CONTENT = """\ +import os + +from pydantic_ai import Agent +from pydantic_ai.models.openai import OpenAIChatModel +from pydantic_ai.providers.openai import OpenAIProvider + +from src.memory.history import get_pai_history, append_pai_messages + + +def get_llm_response(messages: list[dict], system_prompt: str) -> str: + model_name = os.getenv("MODEL", "llama3.2") + base_url = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1") + user_messages = [m["content"] for m in messages if m["role"] == "user"] + prompt = user_messages[-1] if user_messages else "" + provider = OpenAIProvider(api_key="ollama", base_url=base_url) + model = OpenAIChatModel(model_name, provider=provider) + agent = Agent(model, system_prompt=system_prompt) + result = agent.run_sync(prompt, message_history=get_pai_history()) + append_pai_messages(result.new_messages()) + return result.output +""" + +PYDANTIC_AI_GROQ_LLM_CONTENT = """\ +import os + +from pydantic_ai import Agent + +from src.memory.history import get_pai_history, append_pai_messages + + +def get_llm_response(messages: list[dict], system_prompt: str) -> str: + model = os.getenv("MODEL", "groq:llama-3.1-8b-instant") + user_messages = [m["content"] for m in messages if m["role"] == "user"] + prompt = user_messages[-1] if user_messages else "" + agent = Agent(model, system_prompt=system_prompt) + result = agent.run_sync(prompt, message_history=get_pai_history()) + append_pai_messages(result.new_messages()) + return result.output +""" + +# ─── OpenAI SDK ─────────────────────────────────────────────────────────── + +OPENAI_SDK_OPENAI_LLM_CONTENT = """\ +import os + +from openai import OpenAI + + +def get_llm_response(messages: list[dict], system_prompt: str) -> str: + client = OpenAI(api_key=os.getenv("OPENAI_API_KEY", "")) + model = os.getenv("MODEL", "gpt-4o-mini") + full_messages = [{{"role": "system", "content": system_prompt}}] + messages + response = client.chat.completions.create(model=model, messages=full_messages) + return response.choices[0].message.content or "" +""" + +OPENAI_SDK_OPENROUTER_LLM_CONTENT = """\ +import os + +from openai import OpenAI + + +def get_llm_response(messages: list[dict], system_prompt: str) -> str: + client = OpenAI( + api_key=os.getenv("OPENROUTER_API_KEY", ""), + base_url="https://openrouter.ai/api/v1", + ) + model = os.getenv("MODEL", "openai/gpt-4o-mini") + full_messages = [{{"role": "system", "content": system_prompt}}] + messages + response = client.chat.completions.create(model=model, messages=full_messages) + return response.choices[0].message.content or "" +""" + +OPENAI_SDK_GEMINI_LLM_CONTENT = """\ +import os + +from openai import OpenAI + + +def get_llm_response(messages: list[dict], system_prompt: str) -> str: + client = OpenAI( + api_key=os.getenv("GOOGLE_API_KEY", ""), + base_url="https://generativelanguage.googleapis.com/v1beta/openai/", + ) + model = os.getenv("MODEL", "gemini-1.5-flash") + full_messages = [{{"role": "system", "content": system_prompt}}] + messages + response = client.chat.completions.create(model=model, messages=full_messages) + return response.choices[0].message.content or "" +""" + +OPENAI_SDK_GROQ_LLM_CONTENT = """\ +import os + +from openai import OpenAI + + +def get_llm_response(messages: list[dict], system_prompt: str) -> str: + client = OpenAI( + api_key=os.getenv("GROQ_API_KEY", ""), + base_url="https://api.groq.com/openai/v1", + ) + model = os.getenv("MODEL", "llama-3.1-8b-instant") + full_messages = [{{"role": "system", "content": system_prompt}}] + messages + response = client.chat.completions.create(model=model, messages=full_messages) + return response.choices[0].message.content or "" +""" + +# ─── LiteLLM ────────────────────────────────────────────────────────────── + +LITELLM_OPENAI_LLM_CONTENT = """\ +import os + +import litellm + + +def get_llm_response(messages: list[dict], system_prompt: str) -> str: + model = os.getenv("MODEL", "gpt-4o-mini") + full_messages = [{{"role": "system", "content": system_prompt}}] + messages + response = litellm.completion(model=model, messages=full_messages) + return response.choices[0].message.content or "" +""" + +LITELLM_ANTHROPIC_LLM_CONTENT = """\ +import os + +import litellm + + +def get_llm_response(messages: list[dict], system_prompt: str) -> str: + model = os.getenv("MODEL", "claude-3-5-haiku-latest") + full_messages = [{{"role": "system", "content": system_prompt}}] + messages + response = litellm.completion(model=model, messages=full_messages) + return response.choices[0].message.content or "" +""" + +LITELLM_GEMINI_LLM_CONTENT = """\ +import os + +import litellm + + +def get_llm_response(messages: list[dict], system_prompt: str) -> str: + model = os.getenv("MODEL", "gemini/gemini-1.5-flash") + full_messages = [{{"role": "system", "content": system_prompt}}] + messages + response = litellm.completion(model=model, messages=full_messages) + return response.choices[0].message.content or "" +""" + +LITELLM_OPENROUTER_LLM_CONTENT = """\ +import os + +import litellm + + +def get_llm_response(messages: list[dict], system_prompt: str) -> str: + model = os.getenv("MODEL", "openrouter/openai/gpt-4o-mini") + full_messages = [{{"role": "system", "content": system_prompt}}] + messages + response = litellm.completion(model=model, messages=full_messages) + return response.choices[0].message.content or "" +""" + +LITELLM_OLLAMA_LLM_CONTENT = """\ +import os + +import litellm + + +def get_llm_response(messages: list[dict], system_prompt: str) -> str: + model = os.getenv("MODEL", "ollama/llama3.2") + base_url = os.getenv("OLLAMA_API_BASE", "http://localhost:11434") + full_messages = [{{"role": "system", "content": system_prompt}}] + messages + response = litellm.completion(model=model, messages=full_messages, api_base=base_url) + return response.choices[0].message.content or "" +""" + +LITELLM_GROQ_LLM_CONTENT = """\ +import os + +import litellm + + +def get_llm_response(messages: list[dict], system_prompt: str) -> str: + model = os.getenv("MODEL", "groq/llama-3.1-8b-instant") + full_messages = [{{"role": "system", "content": system_prompt}}] + messages + response = litellm.completion(model=model, messages=full_messages) + return response.choices[0].message.content or "" +""" + +# ─── Main content ───────────────────────────────────────────────────────── + +MAIN_CONTENT_NO_RICH = """\ +from src.chatbot.chat import get_response +from src.config.settings import load_env + + +def main() -> None: + load_env() + print("Chatbot ready. Type 'quit' to exit.\\n") + while True: + user_input = input("You: ").strip() + if not user_input: + continue + if user_input.lower() in ("quit", "exit"): + break + response = get_response(user_input) + print(f"Bot: {{response}}\\n") + + +if __name__ == "__main__": + main() +""" + +MAIN_CONTENT_RICH = """\ +from rich.console import Console +from rich.panel import Panel + +from src.chatbot.chat import get_response +from src.config.settings import load_env + +console = Console() + + +def main() -> None: + load_env() + console.print(Panel.fit("[bold cyan]Chatbot[/bold cyan] — type [dim]quit[/dim] to exit")) + while True: + user_input = console.input("[bold yellow]You:[/bold yellow] ").strip() + if not user_input: + continue + if user_input.lower() in ("quit", "exit"): + break + response = get_response(user_input) + console.print(f"[bold green]Bot:[/bold green] {{response}}\\n") + + +if __name__ == "__main__": + main() +""" + +# ─── Env examples (provider-specific) ───────────────────────────────────── + +ENV_OPENAI = """\ +APP_NAME={project_name} +OPENAI_API_KEY= +MODEL=gpt-4o-mini +""" + +ENV_ANTHROPIC = """\ +APP_NAME={project_name} +ANTHROPIC_API_KEY= +MODEL=claude-3-5-haiku-latest +""" + +ENV_GEMINI = """\ +APP_NAME={project_name} +GOOGLE_API_KEY= +MODEL=gemini-1.5-flash +""" + +ENV_OPENROUTER = """\ +APP_NAME={project_name} +OPENROUTER_API_KEY= +MODEL=openai/gpt-4o-mini +""" + +ENV_OLLAMA = """\ +APP_NAME={project_name} +OLLAMA_BASE_URL=http://localhost:11434 +MODEL=llama3.2 +""" + +# litellm+ollama uses a different env var for the base URL +ENV_LITELLM_OLLAMA = """\ +APP_NAME={project_name} +OLLAMA_API_BASE=http://localhost:11434 +MODEL=ollama/llama3.2 +""" + +ENV_GROQ = """\ +APP_NAME={project_name} +GROQ_API_KEY= +MODEL=llama-3.1-8b-instant +""" + +# Provider-prefixed MODEL for pydantic-ai variants + +ENV_PYDANTIC_OPENAI = """\ +APP_NAME={project_name} +OPENAI_API_KEY= +MODEL=openai:gpt-4o-mini +""" + +ENV_PYDANTIC_ANTHROPIC = """\ +APP_NAME={project_name} +ANTHROPIC_API_KEY= +MODEL=anthropic:claude-3-5-haiku-latest +""" + +ENV_PYDANTIC_GEMINI = """\ +APP_NAME={project_name} +GOOGLE_API_KEY= +MODEL=google:gemini-2.0-flash +""" + +ENV_PYDANTIC_OPENROUTER = """\ +APP_NAME={project_name} +OPENROUTER_API_KEY= +MODEL=openai/gpt-4o-mini +""" + +ENV_PYDANTIC_OLLAMA = """\ +APP_NAME={project_name} +OLLAMA_BASE_URL=http://localhost:11434 +MODEL=llama3.2 +""" + +ENV_PYDANTIC_GROQ = """\ +APP_NAME={project_name} +GROQ_API_KEY= +MODEL=groq:llama-3.1-8b-instant +""" + +ENV_LITELLM_GROQ = """\ +APP_NAME={project_name} +GROQ_API_KEY= +MODEL=groq/llama-3.1-8b-instant +""" + +# ─── Test content ───────────────────────────────────────────────────────── + +# BUG FIX: removed unused imports MagicMock and get_llm_response +# which caused ruff F401 failures in generated projects with ruff extra selected +CONFTEST_CONTENT = """\ +import pytest + +from src.memory.history import clear + + +@pytest.fixture(autouse=True) +def reset_memory(): + \"\"\"Reset conversation history before every test.\"\"\" + clear() + yield + clear() +""" + +TEST_CONTENT = """\ +from unittest.mock import patch + +from src.chatbot.chat import get_response + + +def test_get_response_returns_string(): + with patch("src.chatbot.chat.get_llm_response", return_value="Hello!"): + with patch("src.chatbot.chat.get_system_prompt", return_value="You are helpful."): + result = get_response("Hi") + assert isinstance(result, str) + assert result == "Hello!" + + +def test_generate_response_mock(): + with patch("src.chatbot.chat.get_llm_response", return_value="Mocked response"): + with patch("src.chatbot.chat.get_system_prompt", return_value="prompt"): + result = get_response("test input") + assert result == "Mocked response" + + +def test_get_response_non_empty(): + with patch("src.chatbot.chat.get_llm_response", return_value="Sure!"): + with patch("src.chatbot.chat.get_system_prompt", return_value="prompt"): + result = get_response("Tell me something") + assert result != "" +""" + +# ─── README content ─────────────────────────────────────────────────────── + +def make_readme(framework: str, provider: str) -> str: + provider_key_map = { + "openai": "OPENAI_API_KEY=your-key", + "anthropic": "ANTHROPIC_API_KEY=your-key", + "gemini": "GOOGLE_API_KEY=your-key", + "openrouter": "OPENROUTER_API_KEY=your-key", + "ollama": "OLLAMA_BASE_URL=http://localhost:11434", + "groq": "GROQ_API_KEY=your-key", + } + key_line = provider_key_map.get(provider, "API_KEY=your-key") + return ( + "# {project_name}\n\n" + f"An AI chatbot generated with Spawn, using {framework.title()} + {provider.title()}.\n\n" + "## Getting Started\n\n" + "1. Copy `.env.example` to `.env` and fill in your credentials:\n\n" + "```env\n" + f"{key_line}\n" + "```\n\n" + "2. Run the chatbot:\n\n" + "```bash\n" + "uv run python -m src.main\n" + "```\n\n" + "## Example\n\n" + "```\n" + "You: Hello\n" + "Bot: Hello! How can I help?\n" + "```\n\n" + "## Project Structure\n\n" + "```\n" + "{project_name}/\n" + "├── src/\n" + "│ ├── chatbot/ # Conversation orchestration\n" + "│ ├── providers/ # LLM provider (llm.py)\n" + "│ ├── prompts/ # system.txt\n" + "│ ├── memory/ # Runtime conversation history\n" + "│ ├── config/ # Settings and env loading\n" + "│ └── main.py\n" + "├── tests/\n" + "├── .env.example\n" + "└── README.md\n" + "```\n\n" + "## Running Tests\n\n" + "```bash\n" + "uv run pytest\n" + "```\n\n" + "## Roadmap (Not Yet Available)\n\n" + "```bash\n" + "spawn add rag\n" + "spawn add tools\n" + "spawn add vector-db\n" + "spawn add memory\n" + "spawn add mcp\n" + "spawn add voice\n" + "spawn add web-ui\n" + "spawn add streaming\n" + "spawn add observability\n" + "```\n" + ) + +# ─── GitHub Actions ─────────────────────────────────────────────────────── + +GITHUB_ACTIONS_CI_BASE = """\ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + ci: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v5 + + - name: Install dependencies + run: uv sync +""" + +GITHUB_ACTIONS_CI_RUFF_STEP = """\ + - name: Lint + run: uv run ruff check . +""" + +GITHUB_ACTIONS_CI_PYTEST_STEP = """\ + - name: Test + run: uv run pytest +""" \ No newline at end of file diff --git a/tests/test_chatbot_generator.py b/tests/test_chatbot_generator.py new file mode 100644 index 0000000..9d6a995 --- /dev/null +++ b/tests/test_chatbot_generator.py @@ -0,0 +1,294 @@ +"""Generator integration tests for the AI Chatbot template.""" +import json + +from contextlib import contextmanager +from unittest.mock import patch + +from spawn.core.models import ProjectConfig +from spawn.generators.project_generator import ProjectGenerator +from spawn.templates.chatbot import ChatbotTemplate + + +def _cfg( + name: str = "my-bot", + framework: str = "pydantic-ai", + extras: list[str] | None = None, +) -> ProjectConfig: + return ProjectConfig( + name=name, + template="chatbot", + use_git=False, + framework=framework, + extras=extras or [], + ) + + +@contextmanager +def _mock_uv_and_install(): + with patch("spawn.generators.project_generator.install_packages"), \ + patch("spawn.generators.project_generator.initialize_uv"), \ + patch.object(ChatbotTemplate, "post_install"): + yield + + +# --------------------------------------------------------------------------- +# Structure tests +# --------------------------------------------------------------------------- + + +def test_chatbot_creates_root_dir(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-bot").is_dir() + + +def test_chatbot_creates_chatbot_dir(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-bot" / "src" / "chatbot").is_dir() + + +def test_chatbot_creates_providers_dir(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-bot" / "src" / "providers").is_dir() + + +def test_chatbot_creates_prompts_dir(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-bot" / "src" / "prompts").is_dir() + + +def test_chatbot_creates_tests_dir(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-bot" / "tests").is_dir() + + +# --------------------------------------------------------------------------- +# File tests +# --------------------------------------------------------------------------- + + +def test_chatbot_creates_main(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-bot" / "src" / "main.py").exists() + + +def test_chatbot_creates_chat_py(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-bot" / "src" / "chatbot" / "chat.py").exists() + + +def test_chatbot_creates_llm_py(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-bot" / "src" / "providers" / "llm.py").exists() + + +def test_chatbot_creates_system_prompt(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-bot" / "src" / "prompts" / "system.txt").exists() + + +def test_chatbot_creates_test_file(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-bot" / "tests" / "test_chatbot.py").exists() + + +def test_chatbot_creates_conftest(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-bot" / "tests" / "conftest.py").exists() + + +def test_chatbot_creates_env_example(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-bot" / ".env.example").exists() + + +# --------------------------------------------------------------------------- +# Content tests +# --------------------------------------------------------------------------- + + +def test_chatbot_pydantic_ai_env_has_correct_model_format(tmp_path, monkeypatch): + """pydantic-ai .env.example must use openai:gpt-4o-mini prefix.""" + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg(framework="pydantic-ai")) + env = (tmp_path / "my-bot" / ".env.example").read_text(encoding="utf-8") + assert "MODEL=openai:gpt-4o-mini" in env + assert "BASE_URL" not in env + + +def test_chatbot_openai_sdk_env_has_plain_model(tmp_path, monkeypatch): + """openai-sdk .env.example must use plain model name.""" + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg(framework="openai-sdk")) + env = (tmp_path / "my-bot" / ".env.example").read_text(encoding="utf-8") + assert "MODEL=gpt-4o-mini" in env + + +def test_chatbot_readme_contains_project_name(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg(name="my-assistant")) + readme = (tmp_path / "my-assistant" / "README.md").read_text(encoding="utf-8") + assert "my-assistant" in readme + + +def test_chatbot_pydantic_ai_llm_uses_agent(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg(framework="pydantic-ai")) + llm = (tmp_path / "my-bot" / "src" / "providers" / "llm.py").read_text( + encoding="utf-8" + ) + assert "pydantic_ai" in llm + assert "Agent" in llm + + +def test_chatbot_openai_sdk_llm_uses_openai_client(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg(framework="openai-sdk")) + llm = (tmp_path / "my-bot" / "src" / "providers" / "llm.py").read_text( + encoding="utf-8" + ) + assert "OpenAI" in llm + assert "pydantic_ai" not in llm + + +# --------------------------------------------------------------------------- +# Meta +# --------------------------------------------------------------------------- + + +def test_chatbot_creates_spawn_meta_json(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + config = ProjectConfig( + name="my-bot", template="chatbot", use_git=False, + framework="pydantic-ai", provider="openai", extras=[], + ) + with _mock_uv_and_install(): + ProjectGenerator().generate(config) + meta = json.loads( + (tmp_path / "my-bot" / ".spawn" / "meta.json").read_text(encoding="utf-8") + ) + assert meta["intent"] == "chatbot" + assert meta["framework"] == "pydantic-ai" + assert "provider" in meta + assert meta["provider"] == "openai" + + +def test_chatbot_openai_sdk_meta_json(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg(framework="openai-sdk")) + meta = json.loads( + (tmp_path / "my-bot" / ".spawn" / "meta.json").read_text(encoding="utf-8") + ) + assert meta["intent"] == "chatbot" + assert meta["framework"] == "openai-sdk" + assert "provider" in meta + + +# --------------------------------------------------------------------------- +# Dependencies +# --------------------------------------------------------------------------- + + +def test_chatbot_pydantic_ai_install_packages_called(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with patch("spawn.generators.project_generator.install_packages") as mock_install, \ + patch("spawn.generators.project_generator.initialize_uv"), \ + patch.object(ChatbotTemplate, "post_install"): + ProjectGenerator().generate(_cfg(framework="pydantic-ai")) + args = mock_install.call_args[0][1] + assert "pydantic-ai" in args + assert "python-dotenv" in args + + +def test_chatbot_openai_sdk_install_packages_called(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with patch("spawn.generators.project_generator.install_packages") as mock_install, \ + patch("spawn.generators.project_generator.initialize_uv"), \ + patch.object(ChatbotTemplate, "post_install"): + ProjectGenerator().generate(_cfg(framework="openai-sdk")) + args = mock_install.call_args[0][1] + assert "openai" in args + assert "python-dotenv" in args + assert "pydantic-ai[openai]" not in args + + +def test_chatbot_extras_reach_install_packages(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with patch("spawn.generators.project_generator.install_packages") as mock_install, \ + patch("spawn.generators.project_generator.initialize_uv"), \ + patch.object(ChatbotTemplate, "post_install"): + ProjectGenerator().generate(_cfg(extras=["ruff", "pytest"])) + args = mock_install.call_args[0][1] + assert "ruff" in args + assert "pytest" in args + + +def test_chatbot_meta_json_has_provider(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with patch("spawn.generators.project_generator.install_packages"), \ + patch("spawn.generators.project_generator.initialize_uv"), \ + patch.object(ChatbotTemplate, "post_install"): + config = ProjectConfig( + name="my-bot", template="chatbot", use_git=False, + framework="pydantic-ai", provider="gemini", extras=[], + ) + ProjectGenerator().generate(config) + meta = json.loads( + (tmp_path / "my-bot" / ".spawn" / "meta.json").read_text(encoding="utf-8") + ) + assert meta["provider"] == "gemini" + + +def test_chatbot_pydantic_ai_only_pydantic_dep(tmp_path, monkeypatch): + """pydantic-ai projects should not install openai/anthropic separately.""" + monkeypatch.chdir(tmp_path) + with patch("spawn.generators.project_generator.install_packages") as mock_install, \ + patch("spawn.generators.project_generator.initialize_uv"), \ + patch.object(ChatbotTemplate, "post_install"): + ProjectGenerator().generate(_cfg(framework="pydantic-ai")) + args = mock_install.call_args[0][1] + assert "pydantic-ai" in args + assert "openai" not in args + assert "anthropic" not in args + assert "google-genai" not in args + + +def test_chatbot_memory_history_has_pai_functions(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + mem = (tmp_path / "my-bot" / "src" / "memory" / "history.py").read_text( + encoding="utf-8" + ) + assert "get_pai_history" in mem + assert "append_pai_messages" in mem + assert "_pai_history" in mem diff --git a/tests/test_chatbot_template.py b/tests/test_chatbot_template.py new file mode 100644 index 0000000..1ce2c59 --- /dev/null +++ b/tests/test_chatbot_template.py @@ -0,0 +1,637 @@ +import os +import py_compile +import tempfile + +import pytest + +from spawn.templates.chatbot import ChatbotTemplate +from spawn.templates.chatbot.content import ( + ENV_PYDANTIC_GEMINI, + PYDANTIC_AI_ANTHROPIC_LLM_CONTENT, + PYDANTIC_AI_GEMINI_LLM_CONTENT, + PYDANTIC_AI_OLLAMA_LLM_CONTENT, + PYDANTIC_AI_OPENAI_LLM_CONTENT, + PYDANTIC_AI_OPENROUTER_LLM_CONTENT, +) + +# ─── Construction ───────────────────────────────────────────────────────── + + +def test_default_framework_and_provider(): + t = ChatbotTemplate() + assert t.framework == "pydantic-ai" + assert t.provider == "openai" + assert t.extras == [] + + +def test_explicit_framework_and_provider(): + t = ChatbotTemplate(framework="litellm", provider="anthropic") + assert t.framework == "litellm" + assert t.provider == "anthropic" + + +def test_openai_sdk_openrouter(): + t = ChatbotTemplate(framework="openai-sdk", provider="openrouter") + assert t.framework == "openai-sdk" + assert t.provider == "openrouter" + + +# ─── Folders ────────────────────────────────────────────────────────────── + + +def test_folders_include_all_required(): + t = ChatbotTemplate() + assert "src/chatbot" in t.folders + assert "src/providers" in t.folders + assert "src/prompts" in t.folders + assert "src/memory" in t.folders + assert "src/config" in t.folders + assert "tests" in t.folders + + +# ─── Starter files ──────────────────────────────────────────────────────── + + +def test_starter_files_include_all_required(): + t = ChatbotTemplate() + paths = [p for p, _ in t.starter_files] + assert "src/main.py" in paths + assert "src/chatbot/chat.py" in paths + assert "src/providers/llm.py" in paths + assert "src/prompts/system.txt" in paths + assert "src/memory/history.py" in paths + assert "src/config/settings.py" in paths + assert "tests/test_chatbot.py" in paths + assert ".env.example" in paths + + +def test_starter_file_paths_are_strings(): + t = ChatbotTemplate() + for path, _ in t.starter_files: + assert isinstance(path, str) + + +def test_system_prompt_is_txt_not_py(): + t = ChatbotTemplate() + paths = [p for p, _ in t.starter_files] + assert "src/prompts/system.txt" in paths + assert "src/prompts/system_prompt.py" not in paths + + +# ─── LLM content per combination ────────────────────────────────────────── + + +@pytest.mark.parametrize("framework,provider,expected_import", [ + ("pydantic-ai", "openai", "from pydantic_ai import Agent"), + ("pydantic-ai", "anthropic", "from pydantic_ai import Agent"), + ("pydantic-ai", "gemini", "from pydantic_ai import Agent"), + ("pydantic-ai", "openrouter", "from pydantic_ai import Agent"), + ("pydantic-ai", "ollama", "from pydantic_ai import Agent"), + ("openai-sdk", "openai", "from openai import OpenAI"), + ("openai-sdk", "openrouter", "from openai import OpenAI"), + ("openai-sdk", "gemini", "from openai import OpenAI"), + ("litellm", "openai", "import litellm"), + ("litellm", "anthropic", "import litellm"), + ("litellm", "gemini", "import litellm"), + ("litellm", "openrouter", "import litellm"), + ("litellm", "ollama", "import litellm"), +]) +def test_llm_content_import(framework, provider, expected_import): + t = ChatbotTemplate(framework=framework, provider=provider) + files = dict(t.starter_files) + llm = files["src/providers/llm.py"] + assert expected_import in llm + + +def test_pydantic_ai_llm_uses_result_output(): + t = ChatbotTemplate(framework="pydantic-ai", provider="openai") + files = dict(t.starter_files) + llm = files["src/providers/llm.py"] + assert "result.output" in llm + assert "result.data" not in llm + + +def test_pydantic_ai_no_setdefault_openai_key(): + t = ChatbotTemplate(framework="pydantic-ai", provider="openai") + files = dict(t.starter_files) + llm = files["src/providers/llm.py"] + assert "setdefault" not in llm + + +# ─── Env example per provider ───────────────────────────────────────────── + + +@pytest.mark.parametrize("framework,provider,expected_key", [ + ("pydantic-ai", "openai", "OPENAI_API_KEY"), + ("pydantic-ai", "anthropic", "ANTHROPIC_API_KEY"), + ("pydantic-ai", "gemini", "GOOGLE_API_KEY"), + ("pydantic-ai", "openrouter", "OPENROUTER_API_KEY"), + ("pydantic-ai", "ollama", "OLLAMA_BASE_URL"), + ("openai-sdk", "openai", "OPENAI_API_KEY"), + ("openai-sdk", "openrouter", "OPENROUTER_API_KEY"), + ("openai-sdk", "gemini", "GOOGLE_API_KEY"), + ("litellm", "openai", "OPENAI_API_KEY"), + ("litellm", "anthropic", "ANTHROPIC_API_KEY"), + ("litellm", "gemini", "GOOGLE_API_KEY"), + ("litellm", "openrouter", "OPENROUTER_API_KEY"), + ("litellm", "ollama", "OLLAMA_API_BASE"), +]) +def test_env_example_correct_key(framework, provider, expected_key): + t = ChatbotTemplate(framework=framework, provider=provider) + files = dict(t.starter_files) + env = files[".env.example"].format_map({"project_name": "test"}) + assert expected_key in env + + +def test_pydantic_ai_env_has_prefixed_model(): + t = ChatbotTemplate(framework="pydantic-ai", provider="openai") + files = dict(t.starter_files) + env = files[".env.example"].format_map({"project_name": "test"}) + assert "MODEL=openai:" in env + + +def test_openai_sdk_env_has_plain_model(): + t = ChatbotTemplate(framework="openai-sdk", provider="openai") + files = dict(t.starter_files) + env = files[".env.example"].format_map({"project_name": "test"}) + assert "MODEL=gpt-4o-mini" in env + assert "MODEL=openai:" not in env + + +# ─── Dependencies ───────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("framework,provider,expected_dep", [ + ("pydantic-ai", "openai", "pydantic-ai"), + ("openai-sdk", "openai", "openai"), + ("litellm", "openai", "litellm"), + ("litellm", "anthropic", "litellm"), +]) +def test_dependencies_include_correct_package(framework, provider, expected_dep): + t = ChatbotTemplate(framework=framework, provider=provider) + assert expected_dep in t.get_dependencies() + + +def test_all_variants_include_python_dotenv(): + combos = [ + ("pydantic-ai", "openai"), ("pydantic-ai", "anthropic"), + ("openai-sdk", "openai"), ("litellm", "openai"), + ] + for fw, pv in combos: + assert "python-dotenv" in ChatbotTemplate(framework=fw, provider=pv).get_dependencies() + + +def test_extras_add_deps(): + t = ChatbotTemplate(extras=["pytest", "ruff", "rich"]) + deps = t.get_dependencies() + assert "pytest" in deps + assert "ruff" in deps + assert "rich" in deps + + +def test_no_extras_excludes_optional(): + t = ChatbotTemplate() + deps = t.get_dependencies() + assert "pytest" not in deps + assert "ruff" not in deps + assert "rich" not in deps + + +# ─── Rich extra ─────────────────────────────────────────────────────────── + + +def test_rich_extra_uses_rich_main(): + t = ChatbotTemplate(extras=["rich"]) + files = dict(t.starter_files) + main = files["src/main.py"] + assert "from rich" in main + assert "Console" in main + + +def test_no_rich_uses_plain_main(): + t = ChatbotTemplate() + files = dict(t.starter_files) + main = files["src/main.py"] + assert "from rich" not in main + assert 'print(f"Bot:' in main + + +# ─── Memory ─────────────────────────────────────────────────────────────── + + +def test_memory_history_has_required_functions(): + t = ChatbotTemplate() + files = dict(t.starter_files) + mem = files["src/memory/history.py"] + assert "append_user" in mem + assert "append_assistant" in mem + assert "get_history" in mem + assert "clear" in mem + + +# ─── Conftest ───────────────────────────────────────────────────────────── + + +def test_conftest_py_is_generated(): + t = ChatbotTemplate() + paths = [p for p, _ in t.starter_files] + assert "tests/conftest.py" in paths + + +def test_conftest_has_autouse_fixture(): + t = ChatbotTemplate() + files = dict(t.starter_files) + conftest = files["tests/conftest.py"] + assert "autouse=True" in conftest + assert "clear()" in conftest + assert "reset_memory" in conftest + + +def test_conftest_is_valid_python(): + from spawn.templates.chatbot.content import CONFTEST_CONTENT + _assert_valid_python(CONFTEST_CONTENT, "CONFTEST_CONTENT") + + +# ─── README ─────────────────────────────────────────────────────────────── + + +def test_readme_contains_project_name(): + t = ChatbotTemplate() + readme = t.get_readme_content({"project_name": "my-bot"}) + assert "my-bot" in readme + + +def test_readme_mentions_framework_and_provider(): + t = ChatbotTemplate(framework="litellm", provider="anthropic") + readme = t.get_readme_content({"project_name": "test"}) + assert "Litellm" in readme or "litellm" in readme.lower() + assert "Anthropic" in readme or "anthropic" in readme.lower() + + +# ─── Next steps ─────────────────────────────────────────────────────────── + + +def test_next_steps_contain_run_command(): + t = ChatbotTemplate() + assert any("src.main" in step for step in t.next_steps) + + +def test_next_steps_mention_env(): + t = ChatbotTemplate() + assert any(".env" in step for step in t.next_steps) + + +# ─── pydantic-ai API correctness ──────────────────────────────────────── + + +def test_pydantic_ai_openai_no_api_key_in_run_sync(): + """api_key is not a valid run_sync() kwarg in pydantic-ai 2.x.""" + assert "run_sync(prompt, api_key" not in PYDANTIC_AI_OPENAI_LLM_CONTENT + + +def test_pydantic_ai_anthropic_no_api_key_in_run_sync(): + """api_key is not a valid run_sync() kwarg in pydantic-ai 2.x.""" + assert "run_sync(prompt, api_key" not in PYDANTIC_AI_ANTHROPIC_LLM_CONTENT + + +def test_pydantic_ai_gemini_no_api_key_in_run_sync(): + """api_key is not a valid run_sync() kwarg in pydantic-ai 2.x.""" + assert "run_sync(prompt, api_key" not in PYDANTIC_AI_GEMINI_LLM_CONTENT + + +def test_pydantic_ai_gemini_uses_correct_provider_prefix(): + """pydantic-ai 2.0.0 uses google: not google-gla: for Gemini.""" + assert "google-gla" not in PYDANTIC_AI_GEMINI_LLM_CONTENT + assert "google:" in PYDANTIC_AI_GEMINI_LLM_CONTENT + + +def test_env_pydantic_gemini_uses_correct_model_prefix(): + """ENV_PYDANTIC_GEMINI MODEL must match the provider prefix in llm.py.""" + assert "google-gla" not in ENV_PYDANTIC_GEMINI + assert "MODEL=google:" in ENV_PYDANTIC_GEMINI + + +def test_pydantic_ai_openrouter_uses_openai_chat_model(): + """Correct pydantic-ai 2.0.0 pattern for custom base_url providers.""" + assert "OpenAIChatModel" in PYDANTIC_AI_OPENROUTER_LLM_CONTENT + assert "OpenAIProvider" in PYDANTIC_AI_OPENROUTER_LLM_CONTENT + assert 'model_settings={"provider"' not in PYDANTIC_AI_OPENROUTER_LLM_CONTENT + assert "model_settings" not in PYDANTIC_AI_OPENROUTER_LLM_CONTENT + + +def test_pydantic_ai_ollama_uses_openai_chat_model(): + """Correct pydantic-ai 2.0.0 pattern for local Ollama provider.""" + assert "OpenAIChatModel" in PYDANTIC_AI_OLLAMA_LLM_CONTENT + assert "OpenAIProvider" in PYDANTIC_AI_OLLAMA_LLM_CONTENT + assert "model_settings" not in PYDANTIC_AI_OLLAMA_LLM_CONTENT + + +# ─── Python syntax validity ────────────────────────────────────────────── + + +def _assert_valid_python(content: str, label: str) -> None: + """Write content to a temp file and verify it compiles.""" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".py", delete=False, encoding="utf-8" + ) as f: + f.write(content) + fname = f.name + try: + py_compile.compile(fname, doraise=True) + except py_compile.PyCompileError as e: + raise AssertionError(f"{label} is not valid Python: {e}") from e + finally: + os.unlink(fname) + + +def test_pydantic_ai_openai_llm_is_valid_python(): + _assert_valid_python(PYDANTIC_AI_OPENAI_LLM_CONTENT, "PYDANTIC_AI_OPENAI_LLM_CONTENT") + + +def test_pydantic_ai_anthropic_llm_is_valid_python(): + _assert_valid_python(PYDANTIC_AI_ANTHROPIC_LLM_CONTENT, "PYDANTIC_AI_ANTHROPIC_LLM_CONTENT") + + +def test_pydantic_ai_gemini_llm_is_valid_python(): + _assert_valid_python(PYDANTIC_AI_GEMINI_LLM_CONTENT, "PYDANTIC_AI_GEMINI_LLM_CONTENT") + + +def test_pydantic_ai_openrouter_llm_is_valid_python(): + _assert_valid_python(PYDANTIC_AI_OPENROUTER_LLM_CONTENT, "PYDANTIC_AI_OPENROUTER_LLM_CONTENT") + + +def test_pydantic_ai_ollama_llm_is_valid_python(): + _assert_valid_python(PYDANTIC_AI_OLLAMA_LLM_CONTENT, "PYDANTIC_AI_OLLAMA_LLM_CONTENT") + + +# ─── result.output used (not result.data) ──────────────────────────────── + + +def test_pydantic_ai_openai_uses_result_output(): + assert "result.output" in PYDANTIC_AI_OPENAI_LLM_CONTENT + assert "result.data" not in PYDANTIC_AI_OPENAI_LLM_CONTENT + + +def test_pydantic_ai_anthropic_uses_result_output(): + assert "result.output" in PYDANTIC_AI_ANTHROPIC_LLM_CONTENT + assert "result.data" not in PYDANTIC_AI_ANTHROPIC_LLM_CONTENT + + +def test_pydantic_ai_gemini_uses_result_output(): + assert "result.output" in PYDANTIC_AI_GEMINI_LLM_CONTENT + assert "result.data" not in PYDANTIC_AI_GEMINI_LLM_CONTENT + + +def test_pydantic_ai_openrouter_uses_result_output(): + assert "result.output" in PYDANTIC_AI_OPENROUTER_LLM_CONTENT + + +def test_pydantic_ai_ollama_uses_result_output(): + assert "result.output" in PYDANTIC_AI_OLLAMA_LLM_CONTENT + + +# ─── Memory dual-store ──────────────────────────────────────────────────── + + +def test_memory_has_get_pai_history(): + t = ChatbotTemplate() + files = dict(t.starter_files) + mem = files["src/memory/history.py"] + assert "get_pai_history" in mem + assert "_pai_history" in mem + + +def test_memory_has_append_pai_messages(): + t = ChatbotTemplate() + files = dict(t.starter_files) + mem = files["src/memory/history.py"] + assert "append_pai_messages" in mem + + +def test_memory_clear_resets_both_stores(): + t = ChatbotTemplate() + files = dict(t.starter_files) + mem = files["src/memory/history.py"] + assert "_history.clear()" in mem + assert "_pai_history.clear()" in mem + + +def test_memory_type_checking_guard(): + """pydantic_ai import is guarded by TYPE_CHECKING to avoid + import errors in openai-sdk/litellm projects.""" + t = ChatbotTemplate() + files = dict(t.starter_files) + mem = files["src/memory/history.py"] + assert "TYPE_CHECKING" in mem + assert "from pydantic_ai.messages import ModelMessage" in mem + + +def test_openai_sdk_memory_has_no_pai_history(): + """openai-sdk projects get the base history without pydantic-ai dead code.""" + t = ChatbotTemplate(framework="openai-sdk") + files = dict(t.starter_files) + mem = files["src/memory/history.py"] + assert "get_pai_history" not in mem + assert "_pai_history" not in mem + assert "append_pai_messages" not in mem + + +def test_litellm_memory_has_no_pai_history(): + """litellm projects get the base history without pydantic-ai dead code.""" + t = ChatbotTemplate(framework="litellm") + files = dict(t.starter_files) + mem = files["src/memory/history.py"] + assert "get_pai_history" not in mem + assert "_pai_history" not in mem + assert "append_pai_messages" not in mem + + +# ─── PydanticAI multi-turn memory ──────────────────────────────────────── + + +@pytest.mark.parametrize("provider", [ + "openai", "anthropic", "gemini", "openrouter", "ollama" +]) +def test_pydantic_ai_sends_message_history(provider): + """All pydantic-ai variants must pass message_history to run_sync().""" + t = ChatbotTemplate(framework="pydantic-ai", provider=provider) + files = dict(t.starter_files) + llm = files["src/providers/llm.py"] + assert "message_history=get_pai_history()" in llm, \ + f"pydantic-ai+{provider} missing message_history param" + + +@pytest.mark.parametrize("provider", [ + "openai", "anthropic", "gemini", "openrouter", "ollama" +]) +def test_pydantic_ai_appends_new_messages(provider): + """All pydantic-ai variants must store new messages after each call.""" + t = ChatbotTemplate(framework="pydantic-ai", provider=provider) + files = dict(t.starter_files) + llm = files["src/providers/llm.py"] + assert "append_pai_messages(result.new_messages())" in llm, \ + f"pydantic-ai+{provider} missing append_pai_messages call" + + +@pytest.mark.parametrize("provider", [ + "openai", "anthropic", "gemini", "openrouter", "ollama" +]) +def test_pydantic_ai_imports_memory_functions(provider): + """All pydantic-ai variants must import the pai history functions.""" + t = ChatbotTemplate(framework="pydantic-ai", provider=provider) + files = dict(t.starter_files) + llm = files["src/providers/llm.py"] + assert "from src.memory.history import get_pai_history, append_pai_messages" in llm + + +# ─── Dependency simplification ─────────────────────────────────────────── + + +@pytest.mark.parametrize("provider", [ + "openai", "anthropic", "gemini", "openrouter", "ollama" +]) +def test_pydantic_ai_uses_metapackage_only(provider): + """pydantic-ai metapackage bundles all providers — no separate installs.""" + t = ChatbotTemplate(framework="pydantic-ai", provider=provider) + deps = t.get_dependencies() + assert "pydantic-ai" in deps + assert "openai" not in deps, \ + f"pydantic-ai+{provider} should not list openai separately" + assert "anthropic" not in deps, \ + f"pydantic-ai+{provider} should not list anthropic separately" + assert "google-genai" not in deps, \ + f"pydantic-ai+{provider} should not list google-genai separately" + + +# ─── All 13 combinations generate complete file sets ───────────────────── + +ALL_COMBINATIONS = [ + ("pydantic-ai", "openai"), + ("pydantic-ai", "anthropic"), + ("pydantic-ai", "gemini"), + ("pydantic-ai", "openrouter"), + ("pydantic-ai", "ollama"), + ("pydantic-ai", "groq"), + ("openai-sdk", "openai"), + ("openai-sdk", "openrouter"), + ("openai-sdk", "gemini"), + ("openai-sdk", "groq"), + ("litellm", "openai"), + ("litellm", "anthropic"), + ("litellm", "gemini"), + ("litellm", "openrouter"), + ("litellm", "ollama"), + ("litellm", "groq"), +] + +REQUIRED_FILES = [ + "src/__init__.py", + "src/chatbot/__init__.py", + "src/chatbot/chat.py", + "src/providers/__init__.py", + "src/providers/llm.py", + "src/prompts/__init__.py", + "src/prompts/system.txt", + "src/memory/__init__.py", + "src/memory/history.py", + "src/config/__init__.py", + "src/config/settings.py", + "src/main.py", + "tests/__init__.py", + "tests/conftest.py", + "tests/test_chatbot.py", + ".env.example", +] + + +@pytest.mark.parametrize("framework,provider", ALL_COMBINATIONS) +def test_all_combinations_generate_required_files(framework, provider): + t = ChatbotTemplate(framework=framework, provider=provider) + generated = [p for p, _ in t.starter_files] + for required in REQUIRED_FILES: + assert required in generated, \ + f"{framework}+{provider} missing {required}" + + +@pytest.mark.parametrize("framework,provider", ALL_COMBINATIONS) +def test_all_combinations_llm_compiles(framework, provider): + """Every llm.py variant must be syntactically valid Python.""" + t = ChatbotTemplate(framework=framework, provider=provider) + files = dict(t.starter_files) + llm = files["src/providers/llm.py"] + _assert_valid_python(llm, f"{framework}+{provider} llm.py") + + +@pytest.mark.parametrize("framework,provider", ALL_COMBINATIONS) +def test_all_combinations_memory_compiles(framework, provider): + """memory/history.py must compile for all combinations.""" + t = ChatbotTemplate(framework=framework, provider=provider) + files = dict(t.starter_files) + mem = files["src/memory/history.py"] + _assert_valid_python(mem, f"{framework}+{provider} memory/history.py") + + +@pytest.mark.parametrize("framework,provider", ALL_COMBINATIONS) +def test_all_combinations_have_python_dotenv(framework, provider): + t = ChatbotTemplate(framework=framework, provider=provider) + assert "python-dotenv" in t.get_dependencies(), \ + f"{framework}+{provider} missing python-dotenv" + + +@pytest.mark.parametrize("framework,provider", ALL_COMBINATIONS) +def test_no_utils_dir_generated(framework, provider): + t = ChatbotTemplate(framework=framework, provider=provider) + paths = [p for p, _ in t.starter_files] + assert "src/utils/env.py" not in paths + assert "src/utils/__init__.py" not in paths + assert "src/utils" not in t.folders + + +# ─── Groq-specific ─────────────────────────────────────────────────────── + + +def test_pydantic_ai_groq_uses_groq_prefix(): + t = ChatbotTemplate(framework="pydantic-ai", provider="groq") + files = dict(t.starter_files) + llm = files["src/providers/llm.py"] + assert "groq:llama-3.1-8b-instant" in llm + + +def test_pydantic_ai_groq_has_multi_turn_memory(): + t = ChatbotTemplate(framework="pydantic-ai", provider="groq") + files = dict(t.starter_files) + llm = files["src/providers/llm.py"] + assert "message_history=get_pai_history()" in llm + assert "append_pai_messages(result.new_messages())" in llm + + +def test_openai_sdk_groq_uses_groq_base_url(): + t = ChatbotTemplate(framework="openai-sdk", provider="groq") + files = dict(t.starter_files) + llm = files["src/providers/llm.py"] + assert "api.groq.com" in llm + assert "GROQ_API_KEY" in llm + + +def test_litellm_groq_uses_groq_prefix(): + t = ChatbotTemplate(framework="litellm", provider="groq") + files = dict(t.starter_files) + llm = files["src/providers/llm.py"] + assert "groq/llama-3.1-8b-instant" in llm + + +@pytest.mark.parametrize("framework", ["pydantic-ai", "openai-sdk", "litellm"]) +def test_groq_env_has_groq_api_key(framework): + t = ChatbotTemplate(framework=framework, provider="groq") + files = dict(t.starter_files) + env = files[".env.example"].format_map({"project_name": "test"}) + assert "GROQ_API_KEY" in env + + +@pytest.mark.parametrize("framework", ["pydantic-ai", "openai-sdk", "litellm"]) +def test_groq_deps_correct(framework): + t = ChatbotTemplate(framework=framework, provider="groq") + deps = t.get_dependencies() + assert "python-dotenv" in deps + # groq is bundled in pydantic-ai metapackage, no separate dep needed + assert "groq" not in deps diff --git a/tests/test_generator.py b/tests/test_generator.py index 6ce0e80..d7a537d 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -33,6 +33,17 @@ def _patch_automation_post_install(): return patch.object(AutomationTemplate, "post_install") +def _chatbot_config(name="demo", **kwargs): + """Return a minimal ProjectConfig using the chatbot template.""" + return ProjectConfig(name=name, template="chatbot", use_git=False, **kwargs) + + +def _patch_chatbot_post_install(): + """Context manager that silences ChatbotTemplate.post_install.""" + from spawn.templates.chatbot import ChatbotTemplate + return patch.object(ChatbotTemplate, "post_install") + + # --------------------------------------------------------------------------- # Basic project structure # --------------------------------------------------------------------------- @@ -494,3 +505,113 @@ def test_automation_extras_in_dependencies(): assert "pytest" in deps assert "requests" in deps assert "python-dotenv" in deps + + +# --------------------------------------------------------------------------- +# AI Chatbot generator tests +# --------------------------------------------------------------------------- + + +@patch("spawn.generators.project_generator.install_packages") +@patch("spawn.generators.project_generator.initialize_uv") +def test_chatbot_generator_creates_project(mock_uv, mock_install, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _patch_chatbot_post_install(): + ProjectGenerator().generate(_chatbot_config()) + assert (tmp_path / "demo").exists() + + +@patch("spawn.generators.project_generator.install_packages") +@patch("spawn.generators.project_generator.initialize_uv") +def test_chatbot_generator_creates_folders(mock_uv, mock_install, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _patch_chatbot_post_install(): + ProjectGenerator().generate(_chatbot_config()) + assert (tmp_path / "demo" / "src" / "chatbot").exists() + assert (tmp_path / "demo" / "src" / "providers").exists() + assert (tmp_path / "demo" / "src" / "prompts").exists() + assert (tmp_path / "demo" / "tests").exists() + + +@patch("spawn.generators.project_generator.install_packages") +@patch("spawn.generators.project_generator.initialize_uv") +def test_chatbot_generator_creates_main(mock_uv, mock_install, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _patch_chatbot_post_install(): + ProjectGenerator().generate(_chatbot_config()) + assert (tmp_path / "demo" / "src" / "main.py").exists() + + +@patch("spawn.generators.project_generator.install_packages") +@patch("spawn.generators.project_generator.initialize_uv") +def test_chatbot_generator_creates_env_example(mock_uv, mock_install, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _patch_chatbot_post_install(): + ProjectGenerator().generate(_chatbot_config()) + assert (tmp_path / "demo" / ".env.example").exists() + + +@patch("spawn.generators.project_generator.install_packages") +@patch("spawn.generators.project_generator.initialize_uv") +def test_chatbot_generator_creates_meta_json(mock_uv, mock_install, tmp_path, monkeypatch): + import json + + monkeypatch.chdir(tmp_path) + with _patch_chatbot_post_install(): + ProjectGenerator().generate(_chatbot_config()) + meta = json.loads((tmp_path / "demo" / ".spawn" / "meta.json").read_text()) + assert meta["intent"] == "chatbot" + assert meta["framework"] is None + + +@patch("spawn.generators.project_generator.install_packages") +@patch("spawn.generators.project_generator.initialize_uv") +def test_chatbot_generator_with_openai_sdk_framework(mock_uv, mock_install, tmp_path, monkeypatch): + import json + + monkeypatch.chdir(tmp_path) + config = ProjectConfig( + name="demo", template="chatbot", use_git=False, framework="openai-sdk" + ) + with _patch_chatbot_post_install(): + ProjectGenerator().generate(config) + meta = json.loads((tmp_path / "demo" / ".spawn" / "meta.json").read_text()) + assert meta["framework"] == "openai-sdk" + + +@patch("spawn.generators.project_generator.install_packages") +@patch("spawn.generators.project_generator.initialize_uv") +def test_chatbot_env_example_contains_project_name(mock_uv, mock_install, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _patch_chatbot_post_install(): + ProjectGenerator().generate(_chatbot_config(name="my-bot")) + content = (tmp_path / "my-bot" / ".env.example").read_text(encoding="utf-8") + assert "my-bot" in content + + +def test_chatbot_pydantic_ai_extras_in_dependencies(): + from spawn.core.registry import instantiate_template + + config = ProjectConfig( + name="demo", template="chatbot", use_git=False, + framework="pydantic-ai", extras=["ruff", "pytest"], + ) + deps = instantiate_template(config).get_dependencies() + assert "pydantic-ai" in deps + assert "python-dotenv" in deps + assert "ruff" in deps + assert "pytest" in deps + + +def test_chatbot_openai_sdk_extras_in_dependencies(): + from spawn.core.registry import instantiate_template + + config = ProjectConfig( + name="demo", template="chatbot", use_git=False, + framework="openai-sdk", extras=["pytest"], + ) + deps = instantiate_template(config).get_dependencies() + assert "openai" in deps + assert "python-dotenv" in deps + assert "pytest" in deps + assert "pydantic-ai" not in deps diff --git a/tests/test_prompts.py b/tests/test_prompts.py index 1e44450..13debeb 100644 --- a/tests/test_prompts.py +++ b/tests/test_prompts.py @@ -132,6 +132,7 @@ def test_non_numeric_template_choice_retried(mock_prompt, mock_confirm): ("1", "backend-api"), ("2", "cli"), ("3", "automation"), + ("4", "chatbot"), ], ) @patch("spawn.cli.prompts.typer.confirm", return_value=False) @@ -140,6 +141,8 @@ def test_all_template_choices(mock_confirm, choice, expected_template): side_effects = ["project", choice, "1", ""] # name, choice, framework, extras elif expected_template == "automation": side_effects = ["project", choice, ""] # name, choice, extras (skip) + elif expected_template == "chatbot": + side_effects = ["project", choice, "1", "1", ""] # name, choice, framework, provider, extras else: side_effects = ["project", choice, "1", "1", ""] # name, choice, framework, cli_type, extras with patch( diff --git a/tests/test_registry.py b/tests/test_registry.py index cf68bad..e2920d1 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -24,7 +24,8 @@ def test_list_templates_returns_all(): assert "backend-api" in slugs assert "cli" in slugs assert "automation" in slugs - assert len(slugs) == 3 + assert "chatbot" in slugs + assert len(slugs) == 4 def test_get_metadata_returns_none_for_unknown(): @@ -120,3 +121,31 @@ def test_automation_metadata(): def test_automation_in_list_templates(): slugs = [m.slug for m in list_templates()] assert "automation" in slugs + + +def test_chatbot_template_is_registered(): + from spawn.templates.chatbot import ChatbotTemplate + + template = get_template("chatbot") + assert template is not None + assert isinstance(template, ChatbotTemplate) + + +def test_chatbot_metadata(): + meta = get_metadata("chatbot") + assert meta is not None + assert meta.slug == "chatbot" + assert meta.display_name == "AI Chatbot" + assert "pydantic-ai" in meta.available_frameworks + assert "openai-sdk" in meta.available_frameworks + assert "ruff" in meta.available_extras + assert "pytest" in meta.available_extras + assert "github-actions" in meta.available_extras + assert "ollama" in meta.available_providers + assert "groq" in meta.available_providers + assert meta.available_cli_types == [] + + +def test_chatbot_in_list_templates(): + slugs = [m.slug for m in list_templates()] + assert "chatbot" in slugs diff --git a/uv.lock b/uv.lock index ac2c371..8c92aba 100644 --- a/uv.lock +++ b/uv.lock @@ -142,7 +142,7 @@ wheels = [ [[package]] name = "spawn" -version = "0.5.0" +version = "0.6.0" source = { editable = "." } dependencies = [ { name = "rich" },