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