diff --git a/README.md b/README.md index 79b285c..6a5bc0d 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) | +| **Intent-based templates** | Backend API (FastAPI / Flask / Django), CLI Application (Typer / Click / Argparse), Automation Tool | | **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` | @@ -90,8 +90,9 @@ Spawn rejects names with spaces or special characters, and tells you immediately ``` 1 Backend API 2 CLI Application + 3 Automation Tool -Choose Template [1-2]: 1 +Choose Template [1-3]: 1 ``` **Step 3 — Additional prompts** *(template-dependent)* @@ -247,6 +248,31 @@ uv run python -m src.main greet --- +### `[3]` Automation Tool + +Best for: scheduled jobs, data pipelines, reporting systems, integration automation. + +``` +my-automation/ +├── src/ +│ ├── workflows/ +│ ├── tasks/ +│ ├── integrations/ +│ ├── utils/ +│ └── main.py +├── logs/ +├── tests/ +├── .env.example +└── README.md +``` + +```bash +cd my-automation +uv run python -m src.main +``` + +--- + ## Other Commands ### `spawn doctor` @@ -289,7 +315,7 @@ spawn doctor ./path/to/project ```bash spawn version -# → Spawn v0.4.0 +# → Spawn v0.5.0 ``` ### Publish to GitHub @@ -324,7 +350,7 @@ All tests should pass. If they don't, please [open an issue](https://github.com/ - [x] **Extras system** — ruff, pytest, Docker, GitHub Actions installed automatically (v0.3.0) - [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) -- [ ] **Automation Tool intent** — scripting and scheduling scaffold (v0.5.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) - [ ] **AI Agent intent** — tool-calling agent scaffold (v0.7.0) - [ ] **RAG System intent** — retrieval-augmented generation scaffold (v0.8.0) diff --git a/docs/architecture.md b/docs/architecture.md index d15afa1..3b8e8eb 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.4.0" +├── __init__.py # __version__ via importlib.metadata, fallback "0.5.0" ├── cli/ │ ├── app.py # Typer app: create, version, doctor commands │ └── prompts.py # Interactive prompts; derives menu from registry @@ -47,6 +47,9 @@ src/spawn/ │ ├── cli_application/ │ │ ├── __init__.py # CLITemplate (dispatches by framework + cli_type) │ │ └── content.py # All framework × cli_type content strings +│ ├── automation/ +│ │ ├── __init__.py # AutomationTemplate +│ │ └── content.py # Workflow, task, logger, test, README content strings │ ├── python_script/ # Reserved — not in active menu │ ├── data_science/ # Reserved — not in active menu │ └── ml_project/ # Reserved — not in active menu @@ -133,6 +136,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 | `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 db170e0..863a6d7 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,6 +3,29 @@ All notable changes to Spawn are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## v0.5.0 — 2026 + +### New Features + +- **Automation Tool intent** — generates a workflow-based automation project + with `src/workflows/`, `src/tasks/`, `src/integrations/`, `src/utils/`, + `logs/`, and a working example that runs immediately +- **Logging out of the box** — every generated project includes a `setup_logger()` + utility and writes to `logs/app.log` with no manual configuration required +- **Working example workflow** — `load_sample_data → generate_report → log output` + runs on first `uv run python -m src.main` without modification +- **`.env.example`** — pre-wired environment config template included in every + generated project +- **Automation extras** — opt-in `ruff`, `pytest`, `github-actions`; + installed and wired automatically + +### Internal + +- Registry expanded to three active templates: `backend-api`, `cli`, `automation` +- No new fields added to `ProjectConfig` or `TemplateMetadata` — automation + uses the existing `extras` field; prompt flow adapts automatically via guards +- Version bumped to `0.5.0` + ## v0.4.0 — June 2026 ### New Features diff --git a/docs/commands.md b/docs/commands.md index e1c0917..4e46536 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-2]` | Always | +| 2 | Template list → `Choose Template [1-3]` | 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 | @@ -48,6 +48,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 | **Invalid input error (exact):** ``` @@ -297,6 +298,69 @@ my-cli/ --- +### Automation Tool intent + +Automation Tool has no framework or type selection. After picking template 3, +only extras and Git are prompted. + +#### Extras selection + +``` + 1 ruff + 2 pytest + 3 github-actions + + Enter numbers separated by commas, or press Enter to skip +Extras []: +``` + +| Extra | What it adds | +|---|---| +| `ruff` | Installs `ruff`; appends `[tool.ruff]` to `pyproject.toml` | +| `pytest` | Installs `pytest`; appends `[tool.pytest.ini_options]` to `pyproject.toml` | +| `github-actions` | Writes `.github/workflows/ci.yml` with ruff + pytest steps | + +#### Generated project structure + +``` +my-automation/ +├── src/ +│ ├── __init__.py +│ ├── workflows/ +│ │ ├── __init__.py +│ │ └── report_workflow.py +│ ├── tasks/ +│ │ ├── __init__.py +│ │ ├── data_task.py +│ │ └── report_task.py +│ ├── integrations/ +│ │ └── __init__.py +│ ├── utils/ +│ │ ├── __init__.py +│ │ └── logger.py +│ └── main.py +├── logs/ +│ └── .gitkeep +├── tests/ +│ ├── __init__.py +│ └── test_automation.py +├── .env.example +├── .spawn/ +│ └── meta.json +├── .gitignore +├── README.md +└── pyproject.toml +``` + +#### Next steps + +``` +cd my-automation +uv run python -m src.main +``` + +--- + ### `.spawn/meta.json` Every generated project receives a `.spawn/meta.json` file: @@ -305,7 +369,7 @@ Every generated project receives a `.spawn/meta.json` file: { "intent": "backend-api", "framework": "fastapi", - "spawn_version": "0.4.0" + "spawn_version": "0.5.0" } ``` @@ -379,7 +443,7 @@ Prints the installed package version. **Output (exact):** ``` -Spawn v0.4.0 +Spawn v0.5.0 ``` --- diff --git a/docs/getting-started.md b/docs/getting-started.md index 39e4cfe..9e6379a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -30,8 +30,9 @@ $ spawn create ``` 1 Backend API 2 CLI Application + 3 Automation Tool -Choose Template [1-2]: 2 +Choose Template [1-3]: 2 1 utility 2 interactive @@ -79,8 +80,9 @@ $ spawn create ``` 1 Backend API 2 CLI Application + 3 Automation Tool -Choose Template [1-2]: 1 +Choose Template [1-3]: 1 1 fastapi 2 flask @@ -163,7 +165,7 @@ spawn version ``` ``` -Spawn v0.4.0 +Spawn v0.5.0 ``` ```bash @@ -185,4 +187,4 @@ spawn doctor - Learn all commands → [commands.md](commands.md) - Understand how Spawn works → [architecture.md](architecture.md) -- See what's changed → [changelog.md](changelog.md) +- See what's changed → [changelog.md](changelog.md) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 8a6d7f6..1b14482 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "spawn" -version = "0.4.0" +version = "0.5.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 456892a..864eaa1 100644 --- a/src/spawn/__init__.py +++ b/src/spawn/__init__.py @@ -3,4 +3,4 @@ try: __version__ = version("spawn") except PackageNotFoundError: - __version__ = "0.4.0" + __version__ = "0.5.0" diff --git a/src/spawn/core/registry.py b/src/spawn/core/registry.py index 3143908..96f9cf7 100644 --- a/src/spawn/core/registry.py +++ b/src/spawn/core/registry.py @@ -8,6 +8,7 @@ # from spawn.templates.ml_project import MLProjectTemplate from spawn.templates.backend_api import BackendAPITemplate from spawn.templates.cli_application import CLITemplate +from spawn.templates.automation import AutomationTemplate from spawn.templates.base import BaseTemplate @@ -45,6 +46,13 @@ class TemplateMetadata: available_extras=["ruff", "pytest", "github-actions"], available_cli_types=["utility", "interactive"], ), + "automation": TemplateMetadata( + slug="automation", + display_name="Automation Tool", + description="Workflow-based automation with logging, tasks, and integrations", + template_class=AutomationTemplate, + available_extras=["ruff", "pytest", "github-actions"], + ), } diff --git a/src/spawn/templates/automation/__init__.py b/src/spawn/templates/automation/__init__.py new file mode 100644 index 0000000..21213ee --- /dev/null +++ b/src/spawn/templates/automation/__init__.py @@ -0,0 +1,100 @@ +from pathlib import Path + +from spawn.templates.base import BaseTemplate +from spawn.templates.automation.content import ( + INIT_CONTENT, + MAIN_CONTENT, + LOGGER_CONTENT, + REPORT_WORKFLOW_CONTENT, + DATA_TASK_CONTENT, + REPORT_TASK_CONTENT, + TEST_CONTENT, + ENV_EXAMPLE_CONTENT, + README_CONTENT, + GITHUB_ACTIONS_CI_BASE, + GITHUB_ACTIONS_CI_RUFF_STEP, + GITHUB_ACTIONS_CI_PYTEST_STEP, +) + +AUTOMATION_FOLDERS = [ + "src/workflows", + "src/tasks", + "src/integrations", + "src/utils", + "logs", + "tests", +] + +AUTOMATION_FILES = [ + ("src/__init__.py", INIT_CONTENT), + ("src/workflows/__init__.py", INIT_CONTENT), + ("src/workflows/report_workflow.py", REPORT_WORKFLOW_CONTENT), + ("src/tasks/__init__.py", INIT_CONTENT), + ("src/tasks/data_task.py", DATA_TASK_CONTENT), + ("src/tasks/report_task.py", REPORT_TASK_CONTENT), + ("src/integrations/__init__.py", INIT_CONTENT), + ("src/utils/__init__.py", INIT_CONTENT), + ("src/utils/logger.py", LOGGER_CONTENT), + ("src/main.py", MAIN_CONTENT), + ("logs/.gitkeep", INIT_CONTENT), + ("tests/__init__.py", INIT_CONTENT), + ("tests/test_automation.py", TEST_CONTENT), + (".env.example", ENV_EXAMPLE_CONTENT), +] + + +class AutomationTemplate(BaseTemplate): + def __init__( + self, + extras: list[str] | None = None, + ) -> None: + self.extras = extras or [] + super().__init__( + name="Automation Tool", + folders=AUTOMATION_FOLDERS, + starter_files=AUTOMATION_FILES, + next_steps=[ + "cd {project_name}", + "uv run python -m src.main", + ], + ) + + def get_readme_content(self, context: dict) -> str | None: + return README_CONTENT.format_map(context) + + def get_dependencies(self) -> list[str]: + base = ["requests", "python-dotenv"] + if "pytest" in self.extras: + base += ["pytest"] + if "ruff" in self.extras: + base += ["ruff"] + 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]\n" + 'testpaths = ["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_content = GITHUB_ACTIONS_CI_BASE + if "ruff" in self.extras: + ci_content += GITHUB_ACTIONS_CI_RUFF_STEP + if "pytest" in self.extras: + ci_content += GITHUB_ACTIONS_CI_PYTEST_STEP + (workflows_path / "ci.yml").write_text( + ci_content, encoding="utf-8" + ) diff --git a/src/spawn/templates/automation/content.py b/src/spawn/templates/automation/content.py new file mode 100644 index 0000000..13a5fa7 --- /dev/null +++ b/src/spawn/templates/automation/content.py @@ -0,0 +1,181 @@ +INIT_CONTENT = "" + +MAIN_CONTENT = """\ +import logging + +from src.utils.logger import setup_logger +from src.workflows.report_workflow import run_report_workflow + + +def main() -> None: + setup_logger() + logger = logging.getLogger(__name__) + logger.info("Starting {project_name}") + run_report_workflow() + logger.info("{project_name} finished") + + +if __name__ == "__main__": + main() +""" + +LOGGER_CONTENT = """\ +import logging +from pathlib import Path + + +def setup_logger(log_file: str = "logs/app.log") -> None: + Path("logs").mkdir(exist_ok=True) + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + handlers=[ + logging.FileHandler(log_file), + logging.StreamHandler(), + ], + ) +""" + +REPORT_WORKFLOW_CONTENT = """\ +import logging + +from src.tasks.data_task import load_sample_data +from src.tasks.report_task import generate_report + +logger = logging.getLogger(__name__) + + +def run_report_workflow() -> None: + logger.info("Workflow started") + data = load_sample_data() + report = generate_report(data) + logger.info("Report: %s", report) + logger.info("Workflow complete") +""" + +DATA_TASK_CONTENT = """\ +import logging + +logger = logging.getLogger(__name__) + + +def load_sample_data() -> dict: + logger.info("Loading sample data") + return {{"records": 42, "source": "local"}} +""" + +REPORT_TASK_CONTENT = """\ +import logging + +logger = logging.getLogger(__name__) + + +def generate_report(data: dict) -> str: + logger.info("Generating report") + return f"Records processed: {{data['records']}}" +""" + +TEST_CONTENT = """\ +from src.tasks.data_task import load_sample_data +from src.tasks.report_task import generate_report + + +def test_load_sample_data(): + data = load_sample_data() + assert isinstance(data, dict) + assert "records" in data + + +def test_generate_report(): + data = {{"records": 10, "source": "test"}} + report = generate_report(data) + assert "10" in report +""" + +ENV_EXAMPLE_CONTENT = """\ +APP_NAME={project_name} +DEBUG=True +""" + +README_CONTENT = """\ +# {project_name} + +An automation project generated with Spawn. + +## Getting Started + +```bash +uv run python -m src.main +``` + +## Project Structure + +``` +{project_name}/ +├── src/ +│ ├── workflows/ # Business workflows +│ ├── tasks/ # Reusable task modules +│ ├── integrations/ # External service connectors +│ ├── utils/ # Shared helpers +│ └── main.py # Entry point +├── logs/ # Application logs +├── tests/ +├── .env.example +└── README.md +``` + +## Running Tests + +```bash +uv run pytest +``` + +## Logs + +Logs are written to `logs/app.log` and printed to the console. + +## Workflow Overview + +Load sample data → Generate report → Log output → Workflow complete + +## Future Expansions + +```bash +spawn add scheduler +spawn add openai +spawn add slack +spawn add database +``` +""" + +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 +""" diff --git a/src/spawn/templates/shared_content.py b/src/spawn/templates/shared_content.py index 1bbd4f4..89ab95c 100644 --- a/src/spawn/templates/shared_content.py +++ b/src/spawn/templates/shared_content.py @@ -47,4 +47,7 @@ # Spawn metadata .spawn/ + +# Logs +logs/*.log """ diff --git a/tests/test_automation_generator.py b/tests/test_automation_generator.py new file mode 100644 index 0000000..b128a57 --- /dev/null +++ b/tests/test_automation_generator.py @@ -0,0 +1,224 @@ +"""Generator integration tests for the Automation Tool 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.automation import AutomationTemplate + + +def _cfg(name: str = "demo", extras: list[str] | None = None) -> ProjectConfig: + return ProjectConfig( + name=name, + template="automation", + use_git=False, + extras=extras or [], + ) + + +@contextmanager +def _mock_uv_and_install(): + with patch("spawn.generators.project_generator.install_packages") as mock_install, \ + patch("spawn.generators.project_generator.initialize_uv") as mock_uv, \ + patch.object(AutomationTemplate, "post_install"): + yield mock_install, mock_uv + + +# --------------------------------------------------------------------------- +# Structure tests +# --------------------------------------------------------------------------- + + +def test_automation_creates_root_dir(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "demo").is_dir() + + +def test_automation_creates_workflows_dir(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "demo" / "src" / "workflows").is_dir() + + +def test_automation_creates_tasks_dir(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "demo" / "src" / "tasks").is_dir() + + +def test_automation_creates_integrations_dir(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "demo" / "src" / "integrations").is_dir() + + +def test_automation_creates_utils_dir(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "demo" / "src" / "utils").is_dir() + + +def test_automation_creates_logs_dir(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "demo" / "logs").is_dir() + + +def test_automation_creates_tests_dir(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "demo" / "tests").is_dir() + + +# --------------------------------------------------------------------------- +# File tests +# --------------------------------------------------------------------------- + + +def test_automation_creates_main(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "demo" / "src" / "main.py").exists() + + +def test_automation_creates_logger(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "demo" / "src" / "utils" / "logger.py").exists() + + +def test_automation_creates_report_workflow(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "demo" / "src" / "workflows" / "report_workflow.py").exists() + + +def test_automation_creates_data_task(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "demo" / "src" / "tasks" / "data_task.py").exists() + + +def test_automation_creates_report_task(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "demo" / "src" / "tasks" / "report_task.py").exists() + + +def test_automation_creates_test_file(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "demo" / "tests" / "test_automation.py").exists() + + +def test_automation_creates_env_example(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "demo" / ".env.example").exists() + + +def test_automation_creates_logs_gitkeep(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "demo" / "logs" / ".gitkeep").exists() + + +# --------------------------------------------------------------------------- +# Content tests +# --------------------------------------------------------------------------- + + +def test_automation_main_contains_project_name(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg(name="my-bot")) + content = (tmp_path / "my-bot" / "src" / "main.py").read_text(encoding="utf-8") + assert "my-bot" in content + + +def test_automation_readme_contains_project_name(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg(name="my-bot")) + readme = (tmp_path / "my-bot" / "README.md").read_text(encoding="utf-8") + assert "my-bot" in readme + + +def test_automation_env_example_contains_project_name(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg(name="my-bot")) + env = (tmp_path / "my-bot" / ".env.example").read_text(encoding="utf-8") + assert "my-bot" in env + + +def test_automation_gitignore_exists(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "demo" / ".gitignore").exists() + + +def test_automation_gitignore_excludes_log_files(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + gitignore = (tmp_path / "demo" / ".gitignore").read_text(encoding="utf-8") + assert "logs/*.log" in gitignore + + +# --------------------------------------------------------------------------- +# Meta +# --------------------------------------------------------------------------- + + +def test_automation_creates_spawn_meta_json(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + meta = json.loads( + (tmp_path / "demo" / ".spawn" / "meta.json").read_text(encoding="utf-8") + ) + assert meta["intent"] == "automation" + assert meta["framework"] is None + + +# --------------------------------------------------------------------------- +# Dependencies +# --------------------------------------------------------------------------- + + +def test_automation_install_packages_called_with_requests(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install() as (mock_install, _): + ProjectGenerator().generate(_cfg()) + args = mock_install.call_args[0][1] + assert "requests" in args + assert "python-dotenv" in args + + +def test_automation_extras_reach_install_packages(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install() as (mock_install, _): + ProjectGenerator().generate(_cfg(extras=["ruff", "pytest"])) + args = mock_install.call_args[0][1] + assert "ruff" in args + assert "pytest" in args diff --git a/tests/test_automation_template.py b/tests/test_automation_template.py new file mode 100644 index 0000000..35b4a49 --- /dev/null +++ b/tests/test_automation_template.py @@ -0,0 +1,68 @@ +from spawn.templates.automation import AutomationTemplate + + +def test_automation_template_default(): + t = AutomationTemplate() + assert t.name == "Automation Tool" + assert t.extras == [] + + +def test_automation_template_folders(): + t = AutomationTemplate() + assert "src/workflows" in t.folders + assert "src/tasks" in t.folders + assert "src/integrations" in t.folders + assert "src/utils" in t.folders + assert "logs" in t.folders + assert "tests" in t.folders + + +def test_automation_template_starter_files(): + t = AutomationTemplate() + paths = [path for path, _ in t.starter_files] + assert "src/main.py" in paths + assert "src/workflows/report_workflow.py" in paths + assert "src/tasks/data_task.py" in paths + assert "src/tasks/report_task.py" in paths + assert "src/utils/logger.py" in paths + assert "tests/test_automation.py" in paths + assert ".env.example" in paths + assert "logs/.gitkeep" in paths + + +def test_automation_base_dependencies(): + t = AutomationTemplate() + deps = t.get_dependencies() + assert "requests" in deps + assert "python-dotenv" in deps + + +def test_automation_extras_add_deps(): + t = AutomationTemplate(extras=["pytest", "ruff"]) + deps = t.get_dependencies() + assert "pytest" in deps + assert "ruff" in deps + + +def test_automation_no_extras_excludes_optional_deps(): + t = AutomationTemplate() + deps = t.get_dependencies() + assert "pytest" not in deps + assert "ruff" not in deps + + +def test_automation_readme_contains_project_name(): + t = AutomationTemplate() + readme = t.get_readme_content({"project_name": "my-automation"}) + assert "my-automation" in readme + + +def test_automation_starter_file_paths_are_strings(): + t = AutomationTemplate() + for path, _ in t.starter_files: + assert isinstance(path, str) + + +def test_automation_next_steps(): + t = AutomationTemplate() + assert any("src.main" in step for step in t.next_steps) diff --git a/tests/test_generator.py b/tests/test_generator.py index 7c55729..6ce0e80 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -22,6 +22,17 @@ def _patch_post_install(): return patch.object(CLITemplate, "post_install") +def _automation_config(name="demo", **kwargs): + """Return a minimal ProjectConfig using the automation template.""" + return ProjectConfig(name=name, template="automation", use_git=False, **kwargs) + + +def _patch_automation_post_install(): + """Context manager that silences AutomationTemplate.post_install.""" + from spawn.templates.automation import AutomationTemplate + return patch.object(AutomationTemplate, "post_install") + + # --------------------------------------------------------------------------- # Basic project structure # --------------------------------------------------------------------------- @@ -402,3 +413,84 @@ def failing_mkdir(self, *args, **kwargs): ProjectGenerator().generate(_cli_config()) assert not (tmp_path / "demo").exists() + + +# --------------------------------------------------------------------------- +# Automation Tool generator tests +# --------------------------------------------------------------------------- + + +@patch("spawn.generators.project_generator.install_packages") +@patch("spawn.generators.project_generator.initialize_uv") +def test_automation_generator_creates_project(mock_uv, mock_install, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _patch_automation_post_install(): + ProjectGenerator().generate(_automation_config()) + assert (tmp_path / "demo").exists() + + +@patch("spawn.generators.project_generator.install_packages") +@patch("spawn.generators.project_generator.initialize_uv") +def test_automation_generator_creates_folders(mock_uv, mock_install, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _patch_automation_post_install(): + ProjectGenerator().generate(_automation_config()) + assert (tmp_path / "demo" / "src" / "workflows").exists() + assert (tmp_path / "demo" / "src" / "tasks").exists() + assert (tmp_path / "demo" / "src" / "integrations").exists() + assert (tmp_path / "demo" / "src" / "utils").exists() + assert (tmp_path / "demo" / "logs").exists() + assert (tmp_path / "demo" / "tests").exists() + + +@patch("spawn.generators.project_generator.install_packages") +@patch("spawn.generators.project_generator.initialize_uv") +def test_automation_generator_creates_main(mock_uv, mock_install, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _patch_automation_post_install(): + ProjectGenerator().generate(_automation_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_automation_generator_creates_env_example(mock_uv, mock_install, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _patch_automation_post_install(): + ProjectGenerator().generate(_automation_config()) + assert (tmp_path / "demo" / ".env.example").exists() + + +@patch("spawn.generators.project_generator.install_packages") +@patch("spawn.generators.project_generator.initialize_uv") +def test_automation_generator_creates_meta_json(mock_uv, mock_install, tmp_path, monkeypatch): + import json + + monkeypatch.chdir(tmp_path) + with _patch_automation_post_install(): + ProjectGenerator().generate(_automation_config()) + meta = json.loads((tmp_path / "demo" / ".spawn" / "meta.json").read_text()) + assert meta["intent"] == "automation" + + +@patch("spawn.generators.project_generator.install_packages") +@patch("spawn.generators.project_generator.initialize_uv") +def test_automation_main_contains_project_name(mock_uv, mock_install, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _patch_automation_post_install(): + ProjectGenerator().generate(_automation_config(name="my-bot")) + content = (tmp_path / "my-bot" / "src" / "main.py").read_text(encoding="utf-8") + assert "my-bot" in content + + +def test_automation_extras_in_dependencies(): + from spawn.core.registry import instantiate_template + + config = ProjectConfig( + name="demo", template="automation", use_git=False, extras=["ruff", "pytest"] + ) + deps = instantiate_template(config).get_dependencies() + assert "ruff" in deps + assert "pytest" in deps + assert "requests" in deps + assert "python-dotenv" in deps diff --git a/tests/test_prompts.py b/tests/test_prompts.py index 9009364..1e44450 100644 --- a/tests/test_prompts.py +++ b/tests/test_prompts.py @@ -131,12 +131,15 @@ def test_non_numeric_template_choice_retried(mock_prompt, mock_confirm): [ ("1", "backend-api"), ("2", "cli"), + ("3", "automation"), ], ) @patch("spawn.cli.prompts.typer.confirm", return_value=False) def test_all_template_choices(mock_confirm, choice, expected_template): if expected_template == "backend-api": side_effects = ["project", choice, "1", ""] # name, choice, framework, extras + elif expected_template == "automation": + side_effects = ["project", choice, ""] # name, choice, extras (skip) 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 88c4717..cf68bad 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -23,7 +23,8 @@ def test_list_templates_returns_all(): slugs = [t.slug for t in templates] assert "backend-api" in slugs assert "cli" in slugs - assert len(slugs) == 2 + assert "automation" in slugs + assert len(slugs) == 3 def test_get_metadata_returns_none_for_unknown(): @@ -94,3 +95,28 @@ def test_cli_metadata(): def test_cli_in_list_templates(): slugs = [m.slug for m in list_templates()] assert "cli" in slugs + + +def test_automation_template_is_registered(): + from spawn.templates.automation import AutomationTemplate + + template = get_template("automation") + assert template is not None + assert isinstance(template, AutomationTemplate) + + +def test_automation_metadata(): + meta = get_metadata("automation") + assert meta is not None + assert meta.slug == "automation" + assert meta.display_name == "Automation Tool" + assert "ruff" in meta.available_extras + assert "pytest" in meta.available_extras + assert "github-actions" in meta.available_extras + assert meta.available_frameworks == [] + assert meta.available_cli_types == [] + + +def test_automation_in_list_templates(): + slugs = [m.slug for m in list_templates()] + assert "automation" in slugs diff --git a/tests/test_templates.py b/tests/test_templates.py index ca9b883..400db76 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -274,6 +274,15 @@ def test_gitignore_does_not_ignore_uv_lock(): assert "uv.lock" not in lines +def test_gitignore_excludes_log_files_not_logs_dir(): + from spawn.templates.shared_content import GITIGNORE_CONTENT + + lines = GITIGNORE_CONTENT.splitlines() + assert "logs/*.log" in lines + assert "logs/" not in lines + assert "logs" not in lines + + def test_flask_dockerfile_exposes_port_5000(): from spawn.templates.backend_api.content import DOCKERFILE_FLASK_CONTENT diff --git a/uv.lock b/uv.lock index 61f3b5a..ac2c371 100644 --- a/uv.lock +++ b/uv.lock @@ -142,7 +142,7 @@ wheels = [ [[package]] name = "spawn" -version = "0.4.0" +version = "0.5.0" source = { editable = "." } dependencies = [ { name = "rich" },