changepacks_python/
package.rs1use 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 assert!(package.dependencies().is_empty());
286
287 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 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}