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 default_publish_command(&self) -> String {
94        "uv publish".to_string()
95    }
96
97    fn dependencies(&self) -> &HashSet<String> {
98        &self.dependencies
99    }
100
101    fn add_dependency(&mut self, dependency: &str) {
102        self.dependencies.insert(dependency.to_string());
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use changepacks_core::UpdateType;
110    use std::fs;
111    use tempfile::TempDir;
112    use tokio::fs::read_to_string;
113
114    #[tokio::test]
115    async fn test_python_package_new() {
116        let package = PythonPackage::new(
117            Some("test-package".to_string()),
118            Some("1.0.0".to_string()),
119            PathBuf::from("/test/pyproject.toml"),
120            PathBuf::from("test/pyproject.toml"),
121        );
122
123        assert_eq!(package.name(), Some("test-package"));
124        assert_eq!(package.version(), Some("1.0.0"));
125        assert_eq!(package.path(), PathBuf::from("/test/pyproject.toml"));
126        assert_eq!(
127            package.relative_path(),
128            PathBuf::from("test/pyproject.toml")
129        );
130        assert_eq!(package.language(), Language::Python);
131        assert!(!package.is_changed());
132        assert_eq!(package.default_publish_command(), "uv publish");
133    }
134
135    #[tokio::test]
136    async fn test_python_package_set_changed() {
137        let mut package = PythonPackage::new(
138            Some("test-package".to_string()),
139            Some("1.0.0".to_string()),
140            PathBuf::from("/test/pyproject.toml"),
141            PathBuf::from("test/pyproject.toml"),
142        );
143
144        assert!(!package.is_changed());
145        package.set_changed(true);
146        assert!(package.is_changed());
147        package.set_changed(false);
148        assert!(!package.is_changed());
149    }
150
151    #[tokio::test]
152    async fn test_python_package_update_version_patch() {
153        let temp_dir = TempDir::new().unwrap();
154        let pyproject_toml = temp_dir.path().join("pyproject.toml");
155        fs::write(
156            &pyproject_toml,
157            r#"[project]
158name = "test-package"
159version = "1.0.0"
160"#,
161        )
162        .unwrap();
163
164        let mut package = PythonPackage::new(
165            Some("test-package".to_string()),
166            Some("1.0.0".to_string()),
167            pyproject_toml.clone(),
168            PathBuf::from("pyproject.toml"),
169        );
170
171        package.update_version(UpdateType::Patch).await.unwrap();
172
173        let content = read_to_string(&pyproject_toml).await.unwrap();
174        assert!(content.contains("version = \"1.0.1\""));
175
176        temp_dir.close().unwrap();
177    }
178
179    #[tokio::test]
180    async fn test_python_package_update_version_minor() {
181        let temp_dir = TempDir::new().unwrap();
182        let pyproject_toml = temp_dir.path().join("pyproject.toml");
183        fs::write(
184            &pyproject_toml,
185            r#"[project]
186name = "test-package"
187version = "1.0.0"
188"#,
189        )
190        .unwrap();
191
192        let mut package = PythonPackage::new(
193            Some("test-package".to_string()),
194            Some("1.0.0".to_string()),
195            pyproject_toml.clone(),
196            PathBuf::from("pyproject.toml"),
197        );
198
199        package.update_version(UpdateType::Minor).await.unwrap();
200
201        let content = read_to_string(&pyproject_toml).await.unwrap();
202        assert!(content.contains("version = \"1.1.0\""));
203
204        temp_dir.close().unwrap();
205    }
206
207    #[tokio::test]
208    async fn test_python_package_update_version_major() {
209        let temp_dir = TempDir::new().unwrap();
210        let pyproject_toml = temp_dir.path().join("pyproject.toml");
211        fs::write(
212            &pyproject_toml,
213            r#"[project]
214name = "test-package"
215version = "1.0.0"
216"#,
217        )
218        .unwrap();
219
220        let mut package = PythonPackage::new(
221            Some("test-package".to_string()),
222            Some("1.0.0".to_string()),
223            pyproject_toml.clone(),
224            PathBuf::from("pyproject.toml"),
225        );
226
227        package.update_version(UpdateType::Major).await.unwrap();
228
229        let content = read_to_string(&pyproject_toml).await.unwrap();
230        assert!(content.contains("version = \"2.0.0\""));
231
232        temp_dir.close().unwrap();
233    }
234
235    #[tokio::test]
236    async fn test_python_package_update_version_preserves_formatting() {
237        let temp_dir = TempDir::new().unwrap();
238        let pyproject_toml = temp_dir.path().join("pyproject.toml");
239        fs::write(
240            &pyproject_toml,
241            r#"[project]
242name = "test-package"
243version = "1.2.3"
244description = "A test package"
245requires-python = ">=3.8"
246
247[dependencies]
248requests = "2.31.0"
249"#,
250        )
251        .unwrap();
252
253        let mut package = PythonPackage::new(
254            Some("test-package".to_string()),
255            Some("1.2.3".to_string()),
256            pyproject_toml.clone(),
257            PathBuf::from("pyproject.toml"),
258        );
259
260        package.update_version(UpdateType::Patch).await.unwrap();
261
262        let content = read_to_string(&pyproject_toml).await.unwrap();
263        assert!(content.contains("version = \"1.2.4\""));
264        assert!(content.contains("name = \"test-package\""));
265        assert!(content.contains("description = \"A test package\""));
266        assert!(content.contains("[dependencies]"));
267
268        temp_dir.close().unwrap();
269    }
270
271    #[test]
272    fn test_python_package_dependencies() {
273        let mut package = PythonPackage::new(
274            Some("test-package".to_string()),
275            Some("1.0.0".to_string()),
276            PathBuf::from("/test/pyproject.toml"),
277            PathBuf::from("test/pyproject.toml"),
278        );
279
280        // Initially empty
281        assert!(package.dependencies().is_empty());
282
283        // Add dependencies
284        package.add_dependency("requests");
285        package.add_dependency("core");
286
287        let deps = package.dependencies();
288        assert_eq!(deps.len(), 2);
289        assert!(deps.contains("requests"));
290        assert!(deps.contains("core"));
291
292        // Adding duplicate should not increase count
293        package.add_dependency("requests");
294        assert_eq!(package.dependencies().len(), 2);
295    }
296}