Skip to main content

changepacks_python/
workspace.rs

1use anyhow::Result;
2use async_trait::async_trait;
3use changepacks_core::{Language, UpdateType, Workspace};
4
5// Seven-field discovered-project declaration plus `new` / `new_discovered`,
6// shared verbatim with the other four identical language types.
7changepacks_core::declare_discovered_project!(pub struct PythonWorkspace);
8
9#[async_trait]
10impl Workspace for PythonWorkspace {
11    // Standard package/workspace accessors.
12    changepacks_core::impl_basic_accessors!();
13
14    // Publishability flag accessor.
15    changepacks_core::impl_publishable_by_default!();
16
17    // Body shared with `PythonPackage::update_version` via the crate-local
18    // helper; the signature stays hand-written because `async_trait` forbids
19    // generating it from a macro (see `bump_pyproject_version` in `lib.rs`).
20    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    // Fixed language accessor.
25    changepacks_core::impl_language!(Language::Python);
26
27    // Const publish defaults.
28    changepacks_core::impl_const_publish_commands!(
29        crate::PUBLISH_COMMAND,
30        crate::DRY_RUN_PUBLISH_COMMAND
31    );
32
33    // Dependency set accessors.
34    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    /// Workspace-side twin of the `PythonPackage` malformed-manifest test: the
202    /// two `update_version` bodies share `bump_pyproject_version`, so both trait
203    /// entry points must be pinned independently or a regression could be
204    /// hidden behind whichever one is still covered.
205    #[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}