Skip to main content

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