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