changepacks_python/
package.rs

1use anyhow::Result;
2use async_trait::async_trait;
3use changepacks_core::{Language, Package, UpdateType};
4use changepacks_utils::next_version;
5use std::path::{Path, PathBuf};
6use tokio::fs::{read_to_string, write};
7use toml_edit::DocumentMut;
8
9#[derive(Debug)]
10pub struct PythonPackage {
11    name: Option<String>,
12    version: Option<String>,
13    path: PathBuf,
14    relative_path: PathBuf,
15    is_changed: bool,
16}
17
18impl PythonPackage {
19    pub fn new(
20        name: Option<String>,
21        version: Option<String>,
22        path: PathBuf,
23        relative_path: PathBuf,
24    ) -> Self {
25        Self {
26            name,
27            version,
28            path,
29            relative_path,
30            is_changed: false,
31        }
32    }
33}
34
35#[async_trait]
36impl Package for PythonPackage {
37    fn name(&self) -> Option<&str> {
38        self.name.as_deref()
39    }
40
41    fn version(&self) -> Option<&str> {
42        self.version.as_deref()
43    }
44
45    fn path(&self) -> &Path {
46        &self.path
47    }
48
49    fn relative_path(&self) -> &Path {
50        &self.relative_path
51    }
52
53    async fn update_version(&mut self, update_type: UpdateType) -> Result<()> {
54        let current_version = self.version.as_deref().unwrap_or("0.0.0");
55        let new_version = next_version(current_version, update_type)?;
56
57        let pyproject_toml_raw = read_to_string(&self.path).await?;
58        let mut pyproject_toml: DocumentMut = pyproject_toml_raw.parse::<DocumentMut>()?;
59        pyproject_toml["project"]["version"] = new_version.clone().into();
60        write(
61            &self.path,
62            format!(
63                "{}{}",
64                pyproject_toml.to_string().trim_end(),
65                if pyproject_toml_raw.ends_with("\n") {
66                    "\n"
67                } else {
68                    ""
69                }
70            ),
71        )
72        .await?;
73        self.version = Some(new_version);
74        Ok(())
75    }
76
77    fn language(&self) -> Language {
78        Language::Python
79    }
80
81    fn set_changed(&mut self, changed: bool) {
82        self.is_changed = changed;
83    }
84
85    fn is_changed(&self) -> bool {
86        self.is_changed
87    }
88
89    fn default_publish_command(&self) -> &'static str {
90        "uv publish"
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use changepacks_core::UpdateType;
98    use std::fs;
99    use tempfile::TempDir;
100    use tokio::fs::read_to_string;
101
102    #[tokio::test]
103    async fn test_python_package_new() {
104        let package = PythonPackage::new(
105            Some("test-package".to_string()),
106            Some("1.0.0".to_string()),
107            PathBuf::from("/test/pyproject.toml"),
108            PathBuf::from("test/pyproject.toml"),
109        );
110
111        assert_eq!(package.name(), Some("test-package"));
112        assert_eq!(package.version(), Some("1.0.0"));
113        assert_eq!(package.path(), PathBuf::from("/test/pyproject.toml"));
114        assert_eq!(
115            package.relative_path(),
116            PathBuf::from("test/pyproject.toml")
117        );
118        assert_eq!(package.language(), Language::Python);
119        assert_eq!(package.is_changed(), false);
120        assert_eq!(package.default_publish_command(), "uv publish");
121    }
122
123    #[tokio::test]
124    async fn test_python_package_set_changed() {
125        let mut package = PythonPackage::new(
126            Some("test-package".to_string()),
127            Some("1.0.0".to_string()),
128            PathBuf::from("/test/pyproject.toml"),
129            PathBuf::from("test/pyproject.toml"),
130        );
131
132        assert_eq!(package.is_changed(), false);
133        package.set_changed(true);
134        assert_eq!(package.is_changed(), true);
135        package.set_changed(false);
136        assert_eq!(package.is_changed(), false);
137    }
138
139    #[tokio::test]
140    async fn test_python_package_update_version_patch() {
141        let temp_dir = TempDir::new().unwrap();
142        let pyproject_toml = temp_dir.path().join("pyproject.toml");
143        fs::write(
144            &pyproject_toml,
145            r#"[project]
146name = "test-package"
147version = "1.0.0"
148"#,
149        )
150        .unwrap();
151
152        let mut package = PythonPackage::new(
153            Some("test-package".to_string()),
154            Some("1.0.0".to_string()),
155            pyproject_toml.clone(),
156            PathBuf::from("pyproject.toml"),
157        );
158
159        package.update_version(UpdateType::Patch).await.unwrap();
160
161        let content = read_to_string(&pyproject_toml).await.unwrap();
162        assert!(content.contains("version = \"1.0.1\""));
163
164        temp_dir.close().unwrap();
165    }
166
167    #[tokio::test]
168    async fn test_python_package_update_version_minor() {
169        let temp_dir = TempDir::new().unwrap();
170        let pyproject_toml = temp_dir.path().join("pyproject.toml");
171        fs::write(
172            &pyproject_toml,
173            r#"[project]
174name = "test-package"
175version = "1.0.0"
176"#,
177        )
178        .unwrap();
179
180        let mut package = PythonPackage::new(
181            Some("test-package".to_string()),
182            Some("1.0.0".to_string()),
183            pyproject_toml.clone(),
184            PathBuf::from("pyproject.toml"),
185        );
186
187        package.update_version(UpdateType::Minor).await.unwrap();
188
189        let content = read_to_string(&pyproject_toml).await.unwrap();
190        assert!(content.contains("version = \"1.1.0\""));
191
192        temp_dir.close().unwrap();
193    }
194
195    #[tokio::test]
196    async fn test_python_package_update_version_major() {
197        let temp_dir = TempDir::new().unwrap();
198        let pyproject_toml = temp_dir.path().join("pyproject.toml");
199        fs::write(
200            &pyproject_toml,
201            r#"[project]
202name = "test-package"
203version = "1.0.0"
204"#,
205        )
206        .unwrap();
207
208        let mut package = PythonPackage::new(
209            Some("test-package".to_string()),
210            Some("1.0.0".to_string()),
211            pyproject_toml.clone(),
212            PathBuf::from("pyproject.toml"),
213        );
214
215        package.update_version(UpdateType::Major).await.unwrap();
216
217        let content = read_to_string(&pyproject_toml).await.unwrap();
218        assert!(content.contains("version = \"2.0.0\""));
219
220        temp_dir.close().unwrap();
221    }
222
223    #[tokio::test]
224    async fn test_python_package_update_version_preserves_formatting() {
225        let temp_dir = TempDir::new().unwrap();
226        let pyproject_toml = temp_dir.path().join("pyproject.toml");
227        fs::write(
228            &pyproject_toml,
229            r#"[project]
230name = "test-package"
231version = "1.2.3"
232description = "A test package"
233requires-python = ">=3.8"
234
235[dependencies]
236requests = "2.31.0"
237"#,
238        )
239        .unwrap();
240
241        let mut package = PythonPackage::new(
242            Some("test-package".to_string()),
243            Some("1.2.3".to_string()),
244            pyproject_toml.clone(),
245            PathBuf::from("pyproject.toml"),
246        );
247
248        package.update_version(UpdateType::Patch).await.unwrap();
249
250        let content = read_to_string(&pyproject_toml).await.unwrap();
251        assert!(content.contains("version = \"1.2.4\""));
252        assert!(content.contains("name = \"test-package\""));
253        assert!(content.contains("description = \"A test package\""));
254        assert!(content.contains("[dependencies]"));
255
256        temp_dir.close().unwrap();
257    }
258}