changepacks_python/
workspace.rs1use anyhow::Result;
2use async_trait::async_trait;
3use changepacks_core::{Language, UpdateType, Workspace};
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 PythonWorkspace {
12 path: PathBuf,
13 relative_path: PathBuf,
14 version: Option<String>,
15 name: Option<String>,
16 is_changed: bool,
17 dependencies: HashSet<String>,
18}
19
20impl PythonWorkspace {
21 pub fn new(
22 name: Option<String>,
23 version: Option<String>,
24 path: PathBuf,
25 relative_path: PathBuf,
26 ) -> Self {
27 Self {
28 path,
29 relative_path,
30 name,
31 version,
32 is_changed: false,
33 dependencies: HashSet::new(),
34 }
35 }
36}
37
38#[async_trait]
39impl Workspace for PythonWorkspace {
40 fn name(&self) -> Option<&str> {
41 self.name.as_deref()
42 }
43
44 fn path(&self) -> &Path {
45 &self.path
46 }
47
48 fn version(&self) -> Option<&str> {
49 self.version.as_deref()
50 }
51
52 async fn update_version(&mut self, update_type: UpdateType) -> Result<()> {
53 let next_version = next_version(
54 self.version.as_ref().unwrap_or(&String::from("0.0.0")),
55 update_type,
56 )?;
57
58 let pyproject_toml_raw = read_to_string(&self.path).await?;
59 let mut pyproject_toml: DocumentMut = pyproject_toml_raw.parse::<DocumentMut>()?;
60 if pyproject_toml.get("project").is_none() {
61 pyproject_toml["project"] = toml_edit::Item::Table(toml_edit::Table::new());
62 }
63 pyproject_toml["project"]["version"] = next_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(next_version);
78 Ok(())
79 }
80
81 fn language(&self) -> Language {
82 Language::Python
83 }
84
85 fn is_changed(&self) -> bool {
86 self.is_changed
87 }
88
89 fn set_changed(&mut self, changed: bool) {
90 self.is_changed = changed;
91 }
92
93 fn relative_path(&self) -> &Path {
94 &self.relative_path
95 }
96
97 fn default_publish_command(&self) -> &'static str {
98 "uv publish"
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_workspace_new() {
120 let workspace = PythonWorkspace::new(
121 Some("test-workspace".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!(workspace.name(), Some("test-workspace"));
128 assert_eq!(workspace.version(), Some("1.0.0"));
129 assert_eq!(workspace.path(), PathBuf::from("/test/pyproject.toml"));
130 assert_eq!(
131 workspace.relative_path(),
132 PathBuf::from("test/pyproject.toml")
133 );
134 assert_eq!(workspace.language(), Language::Python);
135 assert_eq!(workspace.is_changed(), false);
136 assert_eq!(workspace.default_publish_command(), "uv publish");
137 }
138
139 #[tokio::test]
140 async fn test_python_workspace_new_without_name_and_version() {
141 let workspace = PythonWorkspace::new(
142 None,
143 None,
144 PathBuf::from("/test/pyproject.toml"),
145 PathBuf::from("test/pyproject.toml"),
146 );
147
148 assert_eq!(workspace.name(), None);
149 assert_eq!(workspace.version(), None);
150 }
151
152 #[tokio::test]
153 async fn test_python_workspace_set_changed() {
154 let mut workspace = PythonWorkspace::new(
155 Some("test-workspace".to_string()),
156 Some("1.0.0".to_string()),
157 PathBuf::from("/test/pyproject.toml"),
158 PathBuf::from("test/pyproject.toml"),
159 );
160
161 assert_eq!(workspace.is_changed(), false);
162 workspace.set_changed(true);
163 assert_eq!(workspace.is_changed(), true);
164 workspace.set_changed(false);
165 assert_eq!(workspace.is_changed(), false);
166 }
167
168 #[tokio::test]
169 async fn test_python_workspace_update_version_with_existing_project() {
170 let temp_dir = TempDir::new().unwrap();
171 let pyproject_toml = temp_dir.path().join("pyproject.toml");
172 fs::write(
173 &pyproject_toml,
174 r#"[tool.uv.workspace]
175members = ["packages/*"]
176
177[project]
178name = "test-workspace"
179version = "1.0.0"
180"#,
181 )
182 .unwrap();
183
184 let mut workspace = PythonWorkspace::new(
185 Some("test-workspace".to_string()),
186 Some("1.0.0".to_string()),
187 pyproject_toml.clone(),
188 PathBuf::from("pyproject.toml"),
189 );
190
191 workspace.update_version(UpdateType::Patch).await.unwrap();
192
193 let content = read_to_string(&pyproject_toml).await.unwrap();
194 assert!(content.contains("version = \"1.0.1\""));
195
196 temp_dir.close().unwrap();
197 }
198
199 #[tokio::test]
200 async fn test_python_workspace_update_version_without_project_section() {
201 let temp_dir = TempDir::new().unwrap();
202 let pyproject_toml = temp_dir.path().join("pyproject.toml");
203 fs::write(
204 &pyproject_toml,
205 r#"[tool.uv.workspace]
206members = ["packages/*"]
207"#,
208 )
209 .unwrap();
210
211 let mut workspace = PythonWorkspace::new(
212 Some("test-workspace".to_string()),
213 None,
214 pyproject_toml.clone(),
215 PathBuf::from("pyproject.toml"),
216 );
217
218 workspace.update_version(UpdateType::Patch).await.unwrap();
219
220 let content = read_to_string(&pyproject_toml).await.unwrap();
221 assert!(content.contains("[project]"));
222 assert!(content.contains("version = \"0.0.1\""));
223
224 temp_dir.close().unwrap();
225 }
226
227 #[tokio::test]
228 async fn test_python_workspace_update_version_minor() {
229 let temp_dir = TempDir::new().unwrap();
230 let pyproject_toml = temp_dir.path().join("pyproject.toml");
231 fs::write(
232 &pyproject_toml,
233 r#"[tool.uv.workspace]
234members = ["packages/*"]
235
236[project]
237name = "test-workspace"
238version = "1.0.0"
239"#,
240 )
241 .unwrap();
242
243 let mut workspace = PythonWorkspace::new(
244 Some("test-workspace".to_string()),
245 Some("1.0.0".to_string()),
246 pyproject_toml.clone(),
247 PathBuf::from("pyproject.toml"),
248 );
249
250 workspace.update_version(UpdateType::Minor).await.unwrap();
251
252 let content = read_to_string(&pyproject_toml).await.unwrap();
253 assert!(content.contains("version = \"1.1.0\""));
254
255 temp_dir.close().unwrap();
256 }
257
258 #[tokio::test]
259 async fn test_python_workspace_update_version_major() {
260 let temp_dir = TempDir::new().unwrap();
261 let pyproject_toml = temp_dir.path().join("pyproject.toml");
262 fs::write(
263 &pyproject_toml,
264 r#"[tool.uv.workspace]
265members = ["packages/*"]
266
267[project]
268name = "test-workspace"
269version = "1.0.0"
270"#,
271 )
272 .unwrap();
273
274 let mut workspace = PythonWorkspace::new(
275 Some("test-workspace".to_string()),
276 Some("1.0.0".to_string()),
277 pyproject_toml.clone(),
278 PathBuf::from("pyproject.toml"),
279 );
280
281 workspace.update_version(UpdateType::Major).await.unwrap();
282
283 let content = read_to_string(&pyproject_toml).await.unwrap();
284 assert!(content.contains("version = \"2.0.0\""));
285
286 temp_dir.close().unwrap();
287 }
288
289 #[test]
290 fn test_python_workspace_dependencies() {
291 let mut workspace = PythonWorkspace::new(
292 Some("test-workspace".to_string()),
293 Some("1.0.0".to_string()),
294 PathBuf::from("/test/pyproject.toml"),
295 PathBuf::from("test/pyproject.toml"),
296 );
297
298 assert!(workspace.dependencies().is_empty());
300
301 workspace.add_dependency("requests");
303 workspace.add_dependency("core");
304
305 let deps = workspace.dependencies();
306 assert_eq!(deps.len(), 2);
307 assert!(deps.contains("requests"));
308 assert!(deps.contains("core"));
309
310 workspace.add_dependency("requests");
312 assert_eq!(workspace.dependencies().len(), 2);
313 }
314}