Skip to main content

spikard_cli/init/
python.rs

1//! Python project scaffolder for Spikard applications.
2//!
3//! This module provides a complete Python project scaffold with:
4//! - `pyproject.toml` with uv configuration
5//! - Spikard dependency
6//! - Example application with health endpoint
7//! - Test suite with pytest
8//! - `.gitignore` for Python projects
9//! - README with setup instructions
10
11use super::scaffolder::{ProjectScaffolder, ScaffoldedFile};
12use anyhow::Result;
13use std::path::Path;
14use std::path::PathBuf;
15
16/// Python project scaffolder
17pub struct PythonScaffolder;
18
19impl PythonScaffolder {
20    /// Convert a project name to a valid Python package name (`snake_case`)
21    fn to_package_name(project_name: &str) -> String {
22        project_name.replace('-', "_").to_lowercase()
23    }
24
25    /// Generate pyproject.toml content
26    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    /// Generate the main application module
53    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    /// Generate the __init__.py file for the package
73    fn generate_init_py() -> String {
74        r#""""Spikard application package.""""#.to_string()
75    }
76
77    /// Generate test file
78    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    /// Generate .gitignore file
102    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    /// Generate README.md file
140    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}