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 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 assert!(package.dependencies().is_empty());
281
282 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 package.add_dependency("requests");
293 assert_eq!(package.dependencies().len(), 2);
294 }
295}