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