spawnio 1.0.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. spawn/__init__.py +6 -0
  2. spawn/cli/__init__.py +0 -0
  3. spawn/cli/app.py +218 -0
  4. spawn/cli/noninteractive.py +204 -0
  5. spawn/cli/prompts.py +267 -0
  6. spawn/core/__init__.py +0 -0
  7. spawn/core/exceptions.py +7 -0
  8. spawn/core/models.py +21 -0
  9. spawn/core/registry.py +152 -0
  10. spawn/generators/__init__.py +0 -0
  11. spawn/generators/custom_structure.py +508 -0
  12. spawn/generators/project_generator.py +115 -0
  13. spawn/github/__init__.py +0 -0
  14. spawn/github/exceptions.py +5 -0
  15. spawn/github/publisher.py +69 -0
  16. spawn/github/validators.py +23 -0
  17. spawn/templates/__init__.py +0 -0
  18. spawn/templates/agent/__init__.py +182 -0
  19. spawn/templates/agent/content.py +374 -0
  20. spawn/templates/automation/__init__.py +100 -0
  21. spawn/templates/automation/content.py +181 -0
  22. spawn/templates/backend_api/__init__.py +243 -0
  23. spawn/templates/backend_api/content.py +509 -0
  24. spawn/templates/base.py +58 -0
  25. spawn/templates/chatbot/__init__.py +243 -0
  26. spawn/templates/chatbot/content.py +668 -0
  27. spawn/templates/cli_application/__init__.py +211 -0
  28. spawn/templates/cli_application/content.py +512 -0
  29. spawn/templates/data_project/__init__.py +125 -0
  30. spawn/templates/data_project/analysis/__init__.py +74 -0
  31. spawn/templates/data_project/analysis/content.py +190 -0
  32. spawn/templates/data_project/dashboard/__init__.py +61 -0
  33. spawn/templates/data_project/dashboard/content.py +135 -0
  34. spawn/templates/data_project/etl/__init__.py +66 -0
  35. spawn/templates/data_project/etl/content.py +239 -0
  36. spawn/templates/data_project/ml/__init__.py +62 -0
  37. spawn/templates/data_project/ml/content.py +239 -0
  38. spawn/templates/mcp_server/__init__.py +83 -0
  39. spawn/templates/mcp_server/content.py +161 -0
  40. spawn/templates/rag/__init__.py +124 -0
  41. spawn/templates/rag/content.py +463 -0
  42. spawn/templates/shared_content.py +89 -0
  43. spawn/utils/__init__.py +0 -0
  44. spawn/utils/banner.py +41 -0
  45. spawn/utils/console.py +3 -0
  46. spawn/utils/doctor.py +594 -0
  47. spawn/utils/git.py +132 -0
  48. spawn/utils/success.py +47 -0
  49. spawn/utils/theme.py +25 -0
  50. spawn/utils/uv.py +61 -0
  51. spawn/utils/validators.py +15 -0
  52. spawnio-1.0.5.dist-info/METADATA +798 -0
  53. spawnio-1.0.5.dist-info/RECORD +56 -0
  54. spawnio-1.0.5.dist-info/WHEEL +4 -0
  55. spawnio-1.0.5.dist-info/entry_points.txt +2 -0
  56. spawnio-1.0.5.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,239 @@
1
+ ETL_RAW_CSV = """\
2
+ date,category,amount,notes
3
+ 2024-01-05,Sales,1200,Q1 kickoff
4
+ 2024-01-12,sales,430,
5
+ 2024-01-18,SALES,980,follow-up
6
+ 2024-01-25,Marketing,210,campaign
7
+ 2024-02-03,marketing,560,
8
+ 2024-02-10,MARKETING,1450,conference
9
+ 2024-02-14,Operations,330,
10
+ 2024-02-22,operations,390,routine
11
+ 2024-03-01,OPERATIONS,1100,audit
12
+ 2024-03-08,Sales,270,
13
+ 2024-03-15,sales,620,Q2 push
14
+ 2024-03-21,,870,unknown category
15
+ 2024-03-28,Marketing,190,
16
+ 2024-04-04,MARKETING,710,Q2 end
17
+ 2024-04-11,Operations,1340,
18
+ 2024-04-18,Sales,250,follow-up
19
+ """
20
+
21
+ ETL_CLEAN_CONTENT = """\
22
+ import os
23
+ import sys
24
+
25
+ import pandas as pd
26
+ from dotenv import load_dotenv
27
+
28
+ load_dotenv()
29
+
30
+
31
+ def run() -> None:
32
+ input_path = os.getenv("INPUT_PATH", "data/raw.csv")
33
+ output_path = os.getenv("OUTPUT_PATH", "data/cleaned.csv")
34
+
35
+ try:
36
+ df = pd.read_csv(input_path)
37
+ except FileNotFoundError:
38
+ print(f"Input file not found at {{input_path}}. Check INPUT_PATH in .env.")
39
+ sys.exit(1)
40
+
41
+ original_count = len(df)
42
+
43
+ # Normalise category: lowercase and strip whitespace
44
+ df["category"] = df["category"].str.lower().str.strip()
45
+
46
+ # Drop rows with missing values in required columns
47
+ required_cols = ["date", "category", "amount"]
48
+ df = df.dropna(subset=required_cols)
49
+
50
+ cleaned_count = len(df)
51
+ dropped = original_count - cleaned_count
52
+
53
+ df.to_csv(output_path, index=False)
54
+
55
+ print(f"Cleaned {{cleaned_count}} rows (dropped {{dropped}} with missing values).")
56
+ print(f"Output written to {{output_path}}")
57
+
58
+
59
+ if __name__ == "__main__":
60
+ run()
61
+ """
62
+
63
+ ETL_RUN_CONTENT = """\
64
+ import os
65
+
66
+ from dotenv import load_dotenv
67
+
68
+ from pipelines.clean_data import run as clean
69
+
70
+ load_dotenv()
71
+
72
+
73
+ def main() -> None:
74
+ output_path = os.getenv("OUTPUT_PATH", "data/cleaned.csv")
75
+ clean()
76
+ print(f"Pipeline complete. Output written to {{output_path}}")
77
+
78
+
79
+ if __name__ == "__main__":
80
+ main()
81
+ """
82
+
83
+ ETL_ENV_CONTENT = """\
84
+ APP_NAME={project_name}
85
+ INPUT_PATH=data/raw.csv
86
+ OUTPUT_PATH=data/cleaned.csv
87
+ """
88
+
89
+ ETL_TEST_CONTENT = """\
90
+ import os
91
+ from pathlib import Path
92
+
93
+ import pandas as pd
94
+ import pytest
95
+
96
+
97
+ @pytest.fixture()
98
+ def raw_csv(tmp_path: Path) -> Path:
99
+ content = (
100
+ "date,category,amount,notes\\n"
101
+ "2024-01-05,Sales,100,ok\\n"
102
+ "2024-01-06,SALES,200,\\n"
103
+ "2024-01-07,marketing,300,ok\\n"
104
+ "2024-01-08,,400,missing category\\n"
105
+ "2024-01-09,operations,,missing amount\\n"
106
+ )
107
+ p = tmp_path / "raw.csv"
108
+ p.write_text(content, encoding="utf-8")
109
+ return p
110
+
111
+
112
+ def test_clean_produces_lowercase_categories(raw_csv: Path, tmp_path: Path):
113
+ out = tmp_path / "cleaned.csv"
114
+ os.environ["INPUT_PATH"] = str(raw_csv)
115
+ os.environ["OUTPUT_PATH"] = str(out)
116
+
117
+ from pipelines.clean_data import run
118
+ run()
119
+
120
+ df = pd.read_csv(out)
121
+ assert all(df["category"] == df["category"].str.lower().str.strip()), (
122
+ "category values are not all lowercase"
123
+ )
124
+
125
+
126
+ def test_clean_drops_rows_with_missing_required_values(raw_csv: Path, tmp_path: Path):
127
+ out = tmp_path / "cleaned.csv"
128
+ os.environ["INPUT_PATH"] = str(raw_csv)
129
+ os.environ["OUTPUT_PATH"] = str(out)
130
+
131
+ from pipelines.clean_data import run
132
+ run()
133
+
134
+ df = pd.read_csv(out)
135
+ # Rows with missing category or amount should be gone
136
+ assert df["category"].notna().all(), "cleaned CSV still has missing category"
137
+ assert df["amount"].notna().all(), "cleaned CSV still has missing amount"
138
+
139
+
140
+ def test_clean_missing_input_exits(tmp_path: Path):
141
+ os.environ["INPUT_PATH"] = str(tmp_path / "nonexistent.csv")
142
+ os.environ["OUTPUT_PATH"] = str(tmp_path / "out.csv")
143
+
144
+ from pipelines.clean_data import run
145
+ with pytest.raises(SystemExit) as exc_info:
146
+ run()
147
+ assert exc_info.value.code == 1
148
+
149
+
150
+ def test_raw_csv_exists():
151
+ assert Path("data/raw.csv").exists(), "data/raw.csv must exist"
152
+ """
153
+
154
+
155
+ def make_readme() -> str:
156
+ return (
157
+ "# {project_name}\n\n"
158
+ "An ETL Pipeline generated with Spawn using pandas and python-dotenv.\n\n"
159
+ "## Getting Started\n\n"
160
+ "1. Rename `.env.example` to `.env` (optional — defaults work out of the box):\n\n"
161
+ "```bash\n"
162
+ "# .env\n"
163
+ "INPUT_PATH=data/raw.csv\n"
164
+ "OUTPUT_PATH=data/cleaned.csv\n"
165
+ "```\n\n"
166
+ "2. Install dependencies:\n\n"
167
+ "```bash\n"
168
+ "uv sync\n"
169
+ "```\n\n"
170
+ "3. Run the pipeline:\n\n"
171
+ "```bash\n"
172
+ "uv run python -m pipelines.run\n"
173
+ "```\n\n"
174
+ "## Configuration\n\n"
175
+ "`INPUT_PATH` and `OUTPUT_PATH` are read from `.env` (via python-dotenv).\n"
176
+ "Override them to point at any CSV file without editing code:\n\n"
177
+ "```env\n"
178
+ "INPUT_PATH=data/my_data.csv\n"
179
+ "OUTPUT_PATH=data/my_data_clean.csv\n"
180
+ "```\n\n"
181
+ "## What the Pipeline Does\n\n"
182
+ "1. Reads the raw CSV from `INPUT_PATH`\n"
183
+ "2. Normalises the `category` column (lowercase + strip whitespace)\n"
184
+ "3. Drops rows with missing values in required columns\n"
185
+ "4. Writes the cleaned CSV to `OUTPUT_PATH`\n\n"
186
+ "## Project Structure\n\n"
187
+ "```\n"
188
+ "{project_name}/\n"
189
+ "├── data/\n"
190
+ "│ ├── raw.csv # Source data\n"
191
+ "│ └── cleaned.csv # Output (created on first run)\n"
192
+ "├── pipelines/\n"
193
+ "│ ├── clean_data.py # Cleaning logic\n"
194
+ "│ └── run.py # Pipeline entry point\n"
195
+ "├── src/\n"
196
+ "├── tests/\n"
197
+ "│ └── test_etl.py\n"
198
+ "├── .env.example\n"
199
+ "└── README.md\n"
200
+ "```\n\n"
201
+ "## Running Tests\n\n"
202
+ "```bash\n"
203
+ "uv run pytest\n"
204
+ "```\n"
205
+ )
206
+
207
+ # ─── GitHub Actions ───────────────────────────────────────────────────────
208
+
209
+ GITHUB_ACTIONS_CI_BASE = """\
210
+ name: CI
211
+
212
+ on:
213
+ push:
214
+ branches: [main]
215
+ pull_request:
216
+ branches: [main]
217
+
218
+ jobs:
219
+ ci:
220
+ runs-on: ubuntu-latest
221
+
222
+ steps:
223
+ - uses: actions/checkout@v4
224
+
225
+ - uses: astral-sh/setup-uv@v5
226
+
227
+ - name: Install dependencies
228
+ run: uv sync
229
+ """
230
+
231
+ GITHUB_ACTIONS_CI_RUFF_STEP = """\
232
+ - name: Lint
233
+ run: uv run ruff check .
234
+ """
235
+
236
+ GITHUB_ACTIONS_CI_PYTEST_STEP = """\
237
+ - name: Test
238
+ run: uv run pytest
239
+ """
@@ -0,0 +1,62 @@
1
+ from pathlib import Path
2
+
3
+ from spawn.templates.base import BaseTemplate
4
+ from spawn.templates.data_project.ml.content import (
5
+ ML_SAMPLE_CSV,
6
+ ML_TRAIN_CONTENT,
7
+ ML_TEST_CONTENT,
8
+ make_readme,
9
+ GITHUB_ACTIONS_CI_BASE,
10
+ GITHUB_ACTIONS_CI_RUFF_STEP,
11
+ GITHUB_ACTIONS_CI_PYTEST_STEP,
12
+ )
13
+
14
+ FOLDERS = ["data", "models", "experiments", "src", "tests"]
15
+
16
+ FILES = [
17
+ ("data/dataset.csv", ML_SAMPLE_CSV),
18
+ ("src/__init__.py", ""),
19
+ ("src/train.py", ML_TRAIN_CONTENT),
20
+ ("tests/test_ml.py", ML_TEST_CONTENT),
21
+ ("tests/__init__.py", ""),
22
+ ("models/.gitkeep", ""),
23
+ ("experiments/.gitkeep", ""),
24
+ ]
25
+
26
+ DEPENDENCIES = ["pandas", "numpy", "scikit-learn", "joblib"]
27
+
28
+ NEXT_STEPS = [
29
+ "cd {project_name}",
30
+ "uv run python src/train.py",
31
+ ]
32
+
33
+
34
+ class MLProjectTemplate(BaseTemplate):
35
+ data_type = "Machine Learning"
36
+ _CI_BASE = GITHUB_ACTIONS_CI_BASE
37
+ _CI_RUFF_STEP = GITHUB_ACTIONS_CI_RUFF_STEP
38
+ _CI_PYTEST_STEP = GITHUB_ACTIONS_CI_PYTEST_STEP
39
+
40
+ def __init__(self, extras: list[str] | None = None) -> None:
41
+ self.extras = extras or []
42
+ super().__init__(
43
+ name="Data Project",
44
+ folders=list(FOLDERS),
45
+ starter_files=list(FILES),
46
+ next_steps=list(NEXT_STEPS),
47
+ )
48
+
49
+ def get_readme_content(self, context: dict) -> str | None:
50
+ return make_readme().format_map(context)
51
+
52
+ def get_dependencies(self) -> list[str]:
53
+ base = list(DEPENDENCIES)
54
+ if "pytest" in self.extras:
55
+ base.append("pytest")
56
+ if "ruff" in self.extras:
57
+ base.append("ruff")
58
+ return base
59
+
60
+ def post_install(self, project_path: Path) -> None:
61
+ from spawn.templates.data_project import _DataProjectPostInstallMixin
62
+ _DataProjectPostInstallMixin.post_install(self, project_path) # type: ignore[arg-type]
@@ -0,0 +1,239 @@
1
+ ML_SAMPLE_CSV = """\
2
+ feature_1,feature_2,feature_3,feature_4,target
3
+ 2.5,1.4,0.3,0.1,0
4
+ 4.7,3.2,1.3,0.2,0
5
+ 4.6,3.1,1.5,0.2,0
6
+ 5.0,3.6,1.4,0.2,0
7
+ 5.4,3.9,1.7,0.4,0
8
+ 4.6,3.4,1.4,0.3,0
9
+ 5.0,3.4,1.5,0.2,0
10
+ 4.4,2.9,1.4,0.2,0
11
+ 4.9,3.1,1.5,0.1,0
12
+ 5.4,3.7,1.5,0.2,0
13
+ 4.8,3.4,1.6,0.2,0
14
+ 4.8,3.0,1.4,0.1,0
15
+ 4.3,3.0,1.1,0.1,0
16
+ 5.8,4.0,1.2,0.2,0
17
+ 5.7,4.4,1.5,0.4,0
18
+ 5.4,3.9,1.3,0.4,0
19
+ 5.1,3.5,1.4,0.3,0
20
+ 5.7,3.8,1.7,0.3,0
21
+ 5.1,3.8,1.5,0.3,0
22
+ 5.4,3.4,1.7,0.2,0
23
+ 7.0,3.2,4.7,1.4,1
24
+ 6.4,3.2,4.5,1.5,1
25
+ 6.9,3.1,4.9,1.5,1
26
+ 5.5,2.3,4.0,1.3,1
27
+ 6.5,2.8,4.6,1.5,1
28
+ 5.7,2.8,4.5,1.3,1
29
+ 6.3,3.3,4.7,1.6,1
30
+ 4.9,2.4,3.3,1.0,1
31
+ 6.6,2.9,4.6,1.3,1
32
+ 5.2,2.7,3.9,1.4,1
33
+ 5.0,2.0,3.5,1.0,1
34
+ 5.9,3.0,4.2,1.5,1
35
+ 6.0,2.2,4.0,1.0,1
36
+ 6.1,2.9,4.7,1.4,1
37
+ 5.6,2.9,3.6,1.3,1
38
+ 6.7,3.1,4.4,1.4,1
39
+ 5.6,3.0,4.5,1.5,1
40
+ 5.8,2.7,4.1,1.0,1
41
+ 6.2,2.2,4.5,1.5,1
42
+ 5.6,2.5,3.9,1.1,1
43
+ """
44
+
45
+ ML_TRAIN_CONTENT = """\
46
+ import json
47
+ import os
48
+ from datetime import datetime
49
+ from pathlib import Path
50
+
51
+ import joblib
52
+ import pandas as pd
53
+ from sklearn.ensemble import RandomForestClassifier
54
+ from sklearn.metrics import accuracy_score
55
+ from sklearn.model_selection import train_test_split
56
+
57
+
58
+ def train() -> None:
59
+ data_path = Path("data/dataset.csv")
60
+ if not data_path.exists():
61
+ print(f"Dataset not found at {{data_path}}. Ensure data/dataset.csv exists.")
62
+ return
63
+
64
+ df = pd.read_csv(data_path)
65
+ feature_cols = [c for c in df.columns if c != "target"]
66
+ X = df[feature_cols].values
67
+ y = df["target"].values
68
+
69
+ X_train, X_test, y_train, y_test = train_test_split(
70
+ X, y, test_size=0.2, random_state=42
71
+ )
72
+
73
+ clf = RandomForestClassifier(n_estimators=100, random_state=42)
74
+ clf.fit(X_train, y_train)
75
+
76
+ y_pred = clf.predict(X_test)
77
+ accuracy = float(accuracy_score(y_test, y_pred))
78
+ print(f"Test accuracy: {{accuracy:.4f}}")
79
+
80
+ Path("models").mkdir(exist_ok=True)
81
+ model_path = Path("models/model.joblib")
82
+ joblib.dump(clf, model_path)
83
+ print(f"Model saved to {{model_path}}")
84
+
85
+ Path("experiments").mkdir(exist_ok=True)
86
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
87
+ experiment = {{
88
+ "timestamp": timestamp,
89
+ "accuracy": accuracy,
90
+ "model_type": "RandomForestClassifier",
91
+ "n_train": len(X_train),
92
+ "n_test": len(X_test),
93
+ }}
94
+ exp_path = Path(f"experiments/{{timestamp}}.json")
95
+ exp_path.write_text(json.dumps(experiment, indent=2), encoding="utf-8")
96
+ print(f"Experiment logged to {{exp_path}}")
97
+
98
+
99
+ if __name__ == "__main__":
100
+ train()
101
+ """
102
+
103
+ ML_TEST_CONTENT = """\
104
+ import json
105
+ from pathlib import Path
106
+
107
+ import pytest
108
+
109
+
110
+ def test_train_produces_model(tmp_path, monkeypatch):
111
+ import shutil
112
+
113
+ monkeypatch.chdir(tmp_path)
114
+ # Copy the bundled dataset into the tmp working directory
115
+ src_dataset = Path(__file__).parent.parent / "data" / "dataset.csv"
116
+ (tmp_path / "data").mkdir()
117
+ shutil.copy(src_dataset, tmp_path / "data" / "dataset.csv")
118
+
119
+ from src.train import train
120
+ train()
121
+
122
+ model_path = tmp_path / "models" / "model.joblib"
123
+ assert model_path.exists(), "models/model.joblib was not created"
124
+
125
+
126
+ def test_train_accuracy_is_valid(tmp_path, monkeypatch):
127
+ import shutil
128
+
129
+ monkeypatch.chdir(tmp_path)
130
+ src_dataset = Path(__file__).parent.parent / "data" / "dataset.csv"
131
+ (tmp_path / "data").mkdir()
132
+ shutil.copy(src_dataset, tmp_path / "data" / "dataset.csv")
133
+
134
+ from src.train import train
135
+ train()
136
+
137
+ exp_files = list((tmp_path / "experiments").glob("*.json"))
138
+ assert exp_files, "No experiment file written"
139
+ experiment = json.loads(exp_files[0].read_text(encoding="utf-8"))
140
+ accuracy = experiment["accuracy"]
141
+ assert isinstance(accuracy, float)
142
+ assert 0.0 <= accuracy <= 1.0, f"Accuracy out of range: {{accuracy}}"
143
+
144
+
145
+ def test_dataset_csv_exists():
146
+ assert Path("data/dataset.csv").exists(), "data/dataset.csv must exist"
147
+ """
148
+
149
+
150
+ def make_readme() -> str:
151
+ return (
152
+ "# {project_name}\n\n"
153
+ "A Machine Learning project generated with Spawn using scikit-learn.\n\n"
154
+ "## Getting Started\n\n"
155
+ "1. Install dependencies:\n\n"
156
+ "```bash\n"
157
+ "uv sync\n"
158
+ "```\n\n"
159
+ "2. Train the model:\n\n"
160
+ "```bash\n"
161
+ "uv run python src/train.py\n"
162
+ "```\n\n"
163
+ "Each run trains a `RandomForestClassifier` on `data/dataset.csv`, saves\n"
164
+ "the model to `models/model.joblib`, and writes a timestamped JSON to\n"
165
+ "`experiments/` with accuracy and metadata.\n\n"
166
+ "## Example\n\n"
167
+ "```\n"
168
+ "$ uv run python src/train.py\n"
169
+ "Test accuracy: 1.0000\n"
170
+ "Model saved to models/model.joblib\n"
171
+ "Experiment logged to experiments/20240101_120000.json\n"
172
+ "```\n\n"
173
+ "Note: the bundled sample dataset is intentionally easy to classify, so\n"
174
+ "accuracy will typically be 1.0 — replace `data/dataset.csv` with your own\n"
175
+ "data for realistic results.\n\n"
176
+ "## Experiment Tracking\n\n"
177
+ "Every training run appends a new file to `experiments/`:\n\n"
178
+ "```json\n"
179
+ "{{\n"
180
+ ' "timestamp": "20240101_120000",\n'
181
+ ' "accuracy": 1.0,\n'
182
+ ' "model_type": "RandomForestClassifier",\n'
183
+ ' "n_train": 32,\n'
184
+ ' "n_test": 8\n'
185
+ "}}\n"
186
+ "```\n\n"
187
+ "## Project Structure\n\n"
188
+ "```\n"
189
+ "{project_name}/\n"
190
+ "├── data/\n"
191
+ "│ └── dataset.csv # Labeled training data\n"
192
+ "├── models/\n"
193
+ "│ └── model.joblib # Saved model (created on first run)\n"
194
+ "├── experiments/ # Per-run JSON metrics\n"
195
+ "├── src/\n"
196
+ "│ └── train.py # Training script\n"
197
+ "├── tests/\n"
198
+ "│ └── test_ml.py\n"
199
+ "└── README.md\n"
200
+ "```\n\n"
201
+ "## Running Tests\n\n"
202
+ "```bash\n"
203
+ "uv run pytest\n"
204
+ "```\n"
205
+ )
206
+
207
+ # ─── GitHub Actions ───────────────────────────────────────────────────────
208
+
209
+ GITHUB_ACTIONS_CI_BASE = """\
210
+ name: CI
211
+
212
+ on:
213
+ push:
214
+ branches: [main]
215
+ pull_request:
216
+ branches: [main]
217
+
218
+ jobs:
219
+ ci:
220
+ runs-on: ubuntu-latest
221
+
222
+ steps:
223
+ - uses: actions/checkout@v4
224
+
225
+ - uses: astral-sh/setup-uv@v5
226
+
227
+ - name: Install dependencies
228
+ run: uv sync
229
+ """
230
+
231
+ GITHUB_ACTIONS_CI_RUFF_STEP = """\
232
+ - name: Lint
233
+ run: uv run ruff check .
234
+ """
235
+
236
+ GITHUB_ACTIONS_CI_PYTEST_STEP = """\
237
+ - name: Test
238
+ run: uv run pytest
239
+ """
@@ -0,0 +1,83 @@
1
+ from pathlib import Path
2
+
3
+ from spawn.templates.base import BaseTemplate
4
+ from spawn.templates.mcp_server.content import (
5
+ INIT_CONTENT,
6
+ SERVER_CONTENT,
7
+ TEST_CONTENT,
8
+ ENV_CONTENT,
9
+ GITHUB_ACTIONS_CI_BASE,
10
+ GITHUB_ACTIONS_CI_RUFF_STEP,
11
+ GITHUB_ACTIONS_CI_PYTEST_STEP,
12
+ make_readme,
13
+ make_agents_md,
14
+ )
15
+
16
+ MCP_SERVER_FOLDERS = [
17
+ "src",
18
+ "tests",
19
+ ]
20
+
21
+
22
+ def _build_files() -> list:
23
+ return [
24
+ ("src/__init__.py", INIT_CONTENT),
25
+ ("src/server.py", SERVER_CONTENT),
26
+ ("tests/__init__.py", INIT_CONTENT),
27
+ ("tests/test_server.py", TEST_CONTENT),
28
+ (".env.example", ENV_CONTENT),
29
+ ]
30
+
31
+
32
+ class MCPServerTemplate(BaseTemplate):
33
+ def __init__(self, extras: list[str] | None = None) -> None:
34
+ self.extras = extras or []
35
+
36
+ super().__init__(
37
+ name="MCP Server",
38
+ folders=list(MCP_SERVER_FOLDERS),
39
+ starter_files=_build_files(),
40
+ next_steps=[
41
+ "cd {project_name}",
42
+ "uv run python -m src.server",
43
+ "Add this server to Claude Desktop's config (see README.md)",
44
+ ],
45
+ )
46
+
47
+ def get_readme_content(self, context: dict) -> str | None:
48
+ return make_readme().format_map(context)
49
+
50
+ def get_agents_md_content(self, context: dict) -> str | None:
51
+ return make_agents_md().format_map(context)
52
+
53
+ def get_dependencies(self) -> list[str]:
54
+ base = ["mcp"]
55
+ if "pytest" in self.extras:
56
+ base.append("pytest")
57
+ if "ruff" in self.extras:
58
+ base.append("ruff")
59
+ return base
60
+
61
+ def post_install(self, project_path: Path) -> None:
62
+ pyproject = project_path / "pyproject.toml"
63
+ current = pyproject.read_text(encoding="utf-8")
64
+ additions = ""
65
+
66
+ if "pytest" in self.extras:
67
+ additions += "\n[tool.pytest.ini_options]\ntestpaths = [\"tests\"]\n"
68
+
69
+ if "ruff" in self.extras:
70
+ additions += "\n[tool.ruff]\nline-length = 88\n"
71
+
72
+ if additions:
73
+ pyproject.write_text(current + additions, encoding="utf-8")
74
+
75
+ if "github-actions" in self.extras:
76
+ workflows_path = project_path / ".github" / "workflows"
77
+ workflows_path.mkdir(parents=True, exist_ok=True)
78
+ ci = GITHUB_ACTIONS_CI_BASE
79
+ if "ruff" in self.extras:
80
+ ci += GITHUB_ACTIONS_CI_RUFF_STEP
81
+ if "pytest" in self.extras:
82
+ ci += GITHUB_ACTIONS_CI_PYTEST_STEP
83
+ (workflows_path / "ci.yml").write_text(ci, encoding="utf-8")