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.
- spawn/__init__.py +6 -0
- spawn/cli/__init__.py +0 -0
- spawn/cli/app.py +218 -0
- spawn/cli/noninteractive.py +204 -0
- spawn/cli/prompts.py +267 -0
- spawn/core/__init__.py +0 -0
- spawn/core/exceptions.py +7 -0
- spawn/core/models.py +21 -0
- spawn/core/registry.py +152 -0
- spawn/generators/__init__.py +0 -0
- spawn/generators/custom_structure.py +508 -0
- spawn/generators/project_generator.py +115 -0
- spawn/github/__init__.py +0 -0
- spawn/github/exceptions.py +5 -0
- spawn/github/publisher.py +69 -0
- spawn/github/validators.py +23 -0
- spawn/templates/__init__.py +0 -0
- spawn/templates/agent/__init__.py +182 -0
- spawn/templates/agent/content.py +374 -0
- spawn/templates/automation/__init__.py +100 -0
- spawn/templates/automation/content.py +181 -0
- spawn/templates/backend_api/__init__.py +243 -0
- spawn/templates/backend_api/content.py +509 -0
- spawn/templates/base.py +58 -0
- spawn/templates/chatbot/__init__.py +243 -0
- spawn/templates/chatbot/content.py +668 -0
- spawn/templates/cli_application/__init__.py +211 -0
- spawn/templates/cli_application/content.py +512 -0
- spawn/templates/data_project/__init__.py +125 -0
- spawn/templates/data_project/analysis/__init__.py +74 -0
- spawn/templates/data_project/analysis/content.py +190 -0
- spawn/templates/data_project/dashboard/__init__.py +61 -0
- spawn/templates/data_project/dashboard/content.py +135 -0
- spawn/templates/data_project/etl/__init__.py +66 -0
- spawn/templates/data_project/etl/content.py +239 -0
- spawn/templates/data_project/ml/__init__.py +62 -0
- spawn/templates/data_project/ml/content.py +239 -0
- spawn/templates/mcp_server/__init__.py +83 -0
- spawn/templates/mcp_server/content.py +161 -0
- spawn/templates/rag/__init__.py +124 -0
- spawn/templates/rag/content.py +463 -0
- spawn/templates/shared_content.py +89 -0
- spawn/utils/__init__.py +0 -0
- spawn/utils/banner.py +41 -0
- spawn/utils/console.py +3 -0
- spawn/utils/doctor.py +594 -0
- spawn/utils/git.py +132 -0
- spawn/utils/success.py +47 -0
- spawn/utils/theme.py +25 -0
- spawn/utils/uv.py +61 -0
- spawn/utils/validators.py +15 -0
- spawnio-1.0.5.dist-info/METADATA +798 -0
- spawnio-1.0.5.dist-info/RECORD +56 -0
- spawnio-1.0.5.dist-info/WHEEL +4 -0
- spawnio-1.0.5.dist-info/entry_points.txt +2 -0
- 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")
|