changepacks_python/
workspace.rs1use anyhow::Result;
2use async_trait::async_trait;
3use changepacks_core::{Language, UpdateType, Workspace};
4
5changepacks_core::declare_discovered_project!(pub struct PythonWorkspace);
8
9#[async_trait]
10impl Workspace for PythonWorkspace {
11 changepacks_core::impl_basic_accessors!();
13
14 changepacks_core::impl_publishable_by_default!();
16
17 async fn update_version(&mut self, update_type: UpdateType) -> Result<()> {
21 crate::bump_pyproject_version(&mut self.version, &self.path, update_type).await
22 }
23
24 changepacks_core::impl_language!(Language::Python);
26
27 changepacks_core::impl_const_publish_commands!(
29 crate::PUBLISH_COMMAND,
30 crate::DRY_RUN_PUBLISH_COMMAND
31 );
32
33 changepacks_core::impl_dependencies_accessors!();
35}
36
37#[cfg(test)]
38mod tests {
39 use super::*;
40 use changepacks_core::UpdateType;
41 use rstest::rstest;
42 use std::fs;
43 use std::path::PathBuf;
44 use tempfile::TempDir;
45 use tokio::fs::read_to_string;
46
47 fn assert_python_workspace_defaults(workspace: &PythonWorkspace) {
48 assert_eq!(workspace.name(), Some("test-workspace"));
49 assert_eq!(workspace.version(), Some("1.0.0"));
50 assert_eq!(workspace.path(), PathBuf::from("/test/pyproject.toml"));
51 assert_eq!(
52 workspace.relative_path(),
53 PathBuf::from("test/pyproject.toml")
54 );
55 assert_eq!(workspace.language(), Language::Python);
56 assert!(!workspace.is_changed());
57 assert!(workspace.is_publishable_by_default());
58 assert_eq!(workspace.default_publish_command(), "uv publish");
59 assert_eq!(
60 workspace.default_dry_run_publish_command().as_deref(),
61 Some("uv publish --dry-run")
62 );
63 }
64
65 #[test]
66 fn test_python_workspace_new() {
67 let workspace = PythonWorkspace::new(
68 Some("test-workspace".to_string()),
69 Some("1.0.0".to_string()),
70 PathBuf::from("/test/pyproject.toml"),
71 PathBuf::from("test/pyproject.toml"),
72 );
73
74 assert_python_workspace_defaults(&workspace);
75 }
76
77 #[test]
78 fn test_python_workspace_new_without_name_and_version() {
79 let workspace = PythonWorkspace::new(
80 None,
81 None,
82 PathBuf::from("/test/pyproject.toml"),
83 PathBuf::from("test/pyproject.toml"),
84 );
85
86 assert_eq!(workspace.name(), None);
87 assert_eq!(workspace.version(), None);
88 assert!(workspace.is_publishable_by_default());
89 }
90
91 #[rstest]
92 #[case(true)]
93 #[case(false)]
94 fn test_python_workspace_discovered_publishability_survives_fallback_name(
95 #[case] expected: bool,
96 ) {
97 let mut workspace = PythonWorkspace::new_discovered(
98 None,
99 None,
100 PathBuf::from("/test/pyproject.toml"),
101 PathBuf::from("pyproject.toml"),
102 expected,
103 );
104
105 assert_eq!(workspace.is_publishable_by_default(), expected);
106 workspace.set_name("repository-name".to_string());
107 assert_eq!(workspace.name(), Some("repository-name"));
108 assert_eq!(workspace.is_publishable_by_default(), expected);
109 }
110
111 #[test]
112 fn test_python_workspace_set_changed() {
113 changepacks_core::assert_set_changed_roundtrip!(PythonWorkspace::new(
114 Some("test-workspace".to_string()),
115 Some("1.0.0".to_string()),
116 PathBuf::from("/test/pyproject.toml"),
117 PathBuf::from("test/pyproject.toml"),
118 ));
119 }
120
121 #[rstest]
122 #[case(UpdateType::Patch, "1.0.1")]
123 #[case(UpdateType::Minor, "1.1.0")]
124 #[case(UpdateType::Major, "2.0.0")]
125 #[tokio::test]
126 async fn test_python_workspace_update_version_with_existing_project(
127 #[case] update_type: UpdateType,
128 #[case] expected: &str,
129 ) {
130 let temp_dir = TempDir::new().unwrap();
131 let pyproject_toml = temp_dir.path().join("pyproject.toml");
132 fs::write(
133 &pyproject_toml,
134 r#"[tool.uv.workspace]
135members = ["packages/*"]
136
137[project]
138name = "test-workspace"
139version = "1.0.0"
140"#,
141 )
142 .unwrap();
143
144 let mut workspace = PythonWorkspace::new(
145 Some("test-workspace".to_string()),
146 Some("1.0.0".to_string()),
147 pyproject_toml.clone(),
148 PathBuf::from("pyproject.toml"),
149 );
150
151 workspace.update_version(update_type).await.unwrap();
152
153 let content = read_to_string(&pyproject_toml).await.unwrap();
154 assert!(content.contains(&format!("version = \"{expected}\"")));
155
156 temp_dir.close().unwrap();
157 }
158
159 #[tokio::test]
160 async fn test_python_workspace_update_version_without_project_section() {
161 let temp_dir = TempDir::new().unwrap();
162 let pyproject_toml = temp_dir.path().join("pyproject.toml");
163 fs::write(
164 &pyproject_toml,
165 r#"[tool.uv.workspace]
166members = ["packages/*"]
167"#,
168 )
169 .unwrap();
170
171 let mut workspace = PythonWorkspace::new(
172 Some("test-workspace".to_string()),
173 None,
174 pyproject_toml.clone(),
175 PathBuf::from("pyproject.toml"),
176 );
177
178 workspace.update_version(UpdateType::Patch).await.unwrap();
179
180 let content = read_to_string(&pyproject_toml).await.unwrap();
181 assert!(content.contains("[project]"));
182 assert!(content.contains("version = \"0.0.1\""));
183
184 temp_dir.close().unwrap();
185 }
186
187 #[test]
188 fn test_python_workspace_dependencies() {
189 changepacks_core::assert_dependencies_roundtrip!(
190 PythonWorkspace::new(
191 Some("test-workspace".to_string()),
192 Some("1.0.0".to_string()),
193 PathBuf::from("/test/pyproject.toml"),
194 PathBuf::from("test/pyproject.toml"),
195 ),
196 "requests",
197 "core"
198 );
199 }
200
201 #[tokio::test]
206 async fn test_python_workspace_update_version_malformed_manifest_leaves_file_untouched() {
207 let temp_dir = TempDir::new().unwrap();
208 let pyproject_toml = temp_dir.path().join("pyproject.toml");
209 let original = "invalid toml [[[";
210 fs::write(&pyproject_toml, original).unwrap();
211
212 let mut workspace = PythonWorkspace::new(
213 Some("test-workspace".to_string()),
214 Some("1.0.0".to_string()),
215 pyproject_toml.clone(),
216 PathBuf::from("pyproject.toml"),
217 );
218
219 changepacks_utils::assert_malformed_manifest_rejected!(
220 workspace.update_version(UpdateType::Patch).await,
221 &pyproject_toml,
222 "pyproject.toml",
223 original
224 );
225
226 temp_dir.close().unwrap();
227 }
228
229 #[test]
230 fn test_set_name() {
231 changepacks_core::assert_set_name_roundtrip!(PythonWorkspace::new(
232 None,
233 Some("1.0.0".to_string()),
234 PathBuf::from("/test/pyproject.toml"),
235 PathBuf::from("pyproject.toml"),
236 ));
237 }
238}