spikard_cli/init/
python.rs1use super::scaffolder::{ProjectScaffolder, ScaffoldedFile};
12use anyhow::Result;
13use std::path::Path;
14use std::path::PathBuf;
15
16pub struct PythonScaffolder;
18
19impl PythonScaffolder {
20 fn to_package_name(project_name: &str) -> String {
22 project_name.replace('-', "_").to_lowercase()
23 }
24
25 fn generate_pyproject_toml(project_name: &str, _package_name: &str) -> String {
27 let version = env!("CARGO_PKG_VERSION");
28 format!(
29 r#"[project]
30name = "{project_name}"
31version = "0.1.0"
32description = "A Spikard application"
33requires-python = ">=3.10"
34dependencies = [
35 "spikard>={version}",
36]
37
38[build-system]
39requires = ["hatchling"]
40build-backend = "hatchling.build"
41
42[dependency-groups]
43dev = [
44 "pytest>=8.0.0",
45 "pyrefly>=1.1.1",
46 "ruff>=0.3.0",
47]
48"#
49 )
50 }
51
52 fn generate_app_module(_package_name: &str) -> String {
54 format!(
55 r#""""Main application module."""
56from spikard import ServerConfig, Spikard
57
58app = Spikard()
59
60@app.get("/health")
61async def health() -> dict[str, str]:
62 """Health check endpoint."""
63 return {{"status": "ok"}}
64
65
66if __name__ == "__main__":
67 app.run(config=ServerConfig(host="127.0.0.1", port=8000))
68"#
69 )
70 }
71
72 fn generate_init_py() -> String {
74 r#""""Spikard application package.""""#.to_string()
75 }
76
77 fn generate_test_app(package_name: &str) -> String {
79 format!(
80 r#""""Tests for the application."""
81import pytest
82from {package_name}.app import app
83
84
85@pytest.fixture
86def client():
87 """Create a test client."""
88 from spikard.testing import TestClient
89 return TestClient(app)
90
91
92def test_health(client):
93 """Test health endpoint."""
94 response = client.get("/health")
95 assert response.status_code == 200
96 assert response.json() == {{"status": "ok"}}
97"#
98 )
99 }
100
101 fn generate_gitignore() -> String {
103 r"__pycache__/
104*.py[cod]
105*$py.class
106*.so
107.Python
108build/
109develop-eggs/
110dist/
111downloads/
112eggs/
113.eggs/
114lib/
115lib64/
116parts/
117sdist/
118var/
119wheels/
120*.egg-info/
121.installed.cfg
122*.egg
123.pytest_cache/
124.ruff_cache/
125.venv/
126venv/
127ENV/
128env/
129.vscode/
130.idea/
131*.swp
132*.swo
133*~
134.DS_Store
135"
136 .to_string()
137 }
138
139 fn generate_readme(project_name: &str, package_name: &str) -> String {
141 format!(
142 r"# {project_name}
143
144A Spikard application.
145
146## Setup
147
148Install dependencies with uv:
149
150```bash
151uv sync
152```
153
154## Run
155
156Start the development server:
157
158```bash
159uv run python -m {package_name}.app
160```
161
162## Test
163
164Run the test suite:
165
166```bash
167uv run pytest
168```
169
170## Type checking
171
172Run pyrefly to check types:
173
174```bash
175uv run pyrefly check src/{package_name}/
176```
177
178## Linting
179
180Run ruff to lint code:
181
182```bash
183uv run ruff check src/{package_name}/ tests/
184uv run ruff format src/{package_name}/ tests/
185```
186
187## Documentation
188
189For more information about Spikard, visit:
190- [Spikard Documentation](https://spikard.dev)
191- [Spikard GitHub](https://github.com/Goldziher/spikard)
192"
193 )
194 }
195}
196
197impl ProjectScaffolder for PythonScaffolder {
198 fn scaffold(&self, _project_dir: &Path, project_name: &str) -> Result<Vec<ScaffoldedFile>> {
199 let package_name = Self::to_package_name(project_name);
200
201 let mut files = Vec::new();
202
203 files.push(ScaffoldedFile::new(
204 PathBuf::from("pyproject.toml"),
205 Self::generate_pyproject_toml(project_name, &package_name),
206 ));
207
208 files.push(ScaffoldedFile::new(
209 PathBuf::from(format!("src/{package_name}/__init__.py")),
210 Self::generate_init_py(),
211 ));
212
213 files.push(ScaffoldedFile::new(
214 PathBuf::from(format!("src/{package_name}/app.py")),
215 Self::generate_app_module(&package_name),
216 ));
217
218 files.push(ScaffoldedFile::new(
219 PathBuf::from("tests/test_app.py"),
220 Self::generate_test_app(&package_name),
221 ));
222
223 files.push(ScaffoldedFile::new(
224 PathBuf::from(".gitignore"),
225 Self::generate_gitignore(),
226 ));
227
228 files.push(ScaffoldedFile::new(
229 PathBuf::from("README.md"),
230 Self::generate_readme(project_name, &package_name),
231 ));
232
233 Ok(files)
234 }
235
236 fn next_steps(&self, project_name: &str) -> Vec<String> {
237 let package_name = Self::to_package_name(project_name);
238 vec![
239 format!("cd {}", project_name),
240 "uv sync".to_string(),
241 format!("uv run python -m {}.app", package_name),
242 ]
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 #[test]
251 fn test_to_package_name() {
252 assert_eq!(PythonScaffolder::to_package_name("my-app"), "my_app");
253 assert_eq!(PythonScaffolder::to_package_name("MyApp"), "myapp");
254 assert_eq!(PythonScaffolder::to_package_name("my_app"), "my_app");
255 assert_eq!(PythonScaffolder::to_package_name("MY_APP"), "my_app");
256 }
257
258 #[test]
259 #[allow(clippy::cmp_owned)]
260 fn test_scaffold_creates_files() {
261 let files = PythonScaffolder.scaffold(Path::new("."), "test_app").unwrap();
262
263 assert!(files.iter().any(|f| f.path == PathBuf::from("pyproject.toml")));
264 assert!(files.iter().any(|f| f.path == PathBuf::from("tests/test_app.py")));
265 assert!(files.iter().any(|f| f.path == PathBuf::from(".gitignore")));
266 assert!(files.iter().any(|f| f.path == PathBuf::from("README.md")));
267 }
268
269 #[test]
270 fn test_next_steps() {
271 let steps = PythonScaffolder.next_steps("my-app");
272 assert_eq!(steps.len(), 3);
273 assert!(steps[0].contains("my-app"));
274 assert_eq!(steps[1], "uv sync");
275 assert!(steps[2].contains("my_app.app"));
276 }
277
278 #[test]
279 fn test_pyproject_contains_spikard() {
280 let content = PythonScaffolder::generate_pyproject_toml("test-app", "test_app");
281 assert!(content.contains("spikard>="));
282 assert!(content.contains("pytest"));
283 assert!(content.contains("pyrefly"));
284 assert!(!content.contains("[tool.uv.sources]"));
285 }
286
287 #[test]
288 fn test_app_module_contains_health_endpoint() {
289 let content = PythonScaffolder::generate_app_module("test_app");
290 assert!(content.contains("@app.get(\"/health\")"));
291 assert!(content.contains("status"));
292 assert!(content.contains("ok"));
293 assert!(content.contains("Spikard()"));
294 assert!(content.contains("ServerConfig"));
295 }
296}