changepacks_python/
lib.rs1pub mod finder;
10pub mod package;
11pub mod workspace;
12
13pub use finder::PythonProjectFinder;
14
15pub(crate) const PUBLISH_COMMAND: &str = "uv publish";
18
19pub(crate) const DRY_RUN_PUBLISH_COMMAND: &str = "uv publish --dry-run";
23
24use std::path::Path;
25
26use anyhow::Result;
27use changepacks_core::UpdateType;
28use changepacks_utils::{read_and_parse, write_toml_table_version};
29use toml_edit::DocumentMut;
30
31pub(crate) async fn bump_pyproject_version(
45 version: &mut Option<String>,
46 path: &Path,
47 update_type: UpdateType,
48) -> Result<()> {
49 changepacks_utils::bump_version_with(version, path, update_type, async |new| {
50 crate::write_pyproject_version(path, new).await
51 })
52 .await
53}
54
55pub(crate) async fn read_and_parse_pyproject_toml(path: &Path) -> Result<(String, DocumentMut)> {
66 read_and_parse(path, "pyproject.toml", str::parse::<DocumentMut>).await
67}
68
69pub(crate) async fn write_pyproject_version(path: &Path, new_version: &str) -> Result<()> {
112 write_toml_table_version(path, "pyproject.toml", "project", new_version, |doc| {
113 let has_dynamic_version = doc
114 .get("project")
115 .and_then(|project| project.get("dynamic"))
116 .and_then(toml_edit::Item::as_array)
117 .is_some_and(|dynamic| dynamic.iter().any(|item| item.as_str() == Some("version")));
118 if has_dynamic_version {
119 anyhow::bail!(
120 "pyproject.toml {} has backend-managed version in project.dynamic",
121 path.display()
122 );
123 }
124 Ok(())
125 })
126 .await
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132 use changepacks_utils::test_support;
133 use std::fs;
134 use tempfile::TempDir;
135
136 #[tokio::test]
137 async fn test_write_pyproject_version_preserves_complete_trailing_whitespace() {
138 let temp_dir = TempDir::new().unwrap();
139 let pyproject_toml = temp_dir.path().join("pyproject.toml");
140 let suffix = " \t\r\n \n";
141 fs::write(
142 &pyproject_toml,
143 format!("[project]\nversion = \"1.0.0\"{suffix}"),
144 )
145 .unwrap();
146
147 write_pyproject_version(&pyproject_toml, "2.0.0")
148 .await
149 .unwrap();
150
151 assert_eq!(
152 fs::read_to_string(&pyproject_toml).unwrap(),
153 format!("[project]\nversion = \"2.0.0\"{suffix}")
154 );
155 }
156
157 #[tokio::test]
158 async fn test_write_pyproject_version_error_includes_path() {
159 let temp_dir = TempDir::new().unwrap();
160 let pyproject_toml = temp_dir.path().join("pyproject.toml");
161 fs::write(&pyproject_toml, "[project]\nversion = \"1.0.0\"\n").unwrap();
162
163 test_support::set_readonly(&pyproject_toml, true);
166
167 let result = write_pyproject_version(&pyproject_toml, "2.0.0").await;
170
171 test_support::set_readonly(&pyproject_toml, false);
174
175 let err = result.expect_err("write to a readonly pyproject.toml must fail");
176 let chain = format!("{err:#}");
177 assert!(
178 chain.contains(&pyproject_toml.display().to_string()),
179 "error chain should name the manifest path, got: {chain}"
180 );
181 }
182
183 #[tokio::test]
184 async fn test_write_pyproject_version_non_table_project_error_includes_path() {
185 let temp_dir = TempDir::new().unwrap();
186 let pyproject_toml = temp_dir.path().join("pyproject.toml");
187 fs::write(&pyproject_toml, "project = 3\n").unwrap();
188
189 let err = write_pyproject_version(&pyproject_toml, "2.0.0")
190 .await
191 .expect_err("non-table project item must fail");
192 let chain = format!("{err:#}");
193 assert!(
194 chain.contains(&pyproject_toml.display().to_string()),
195 "error chain should name the manifest path, got: {chain}"
196 );
197 assert!(
198 chain.contains("non-table [project]"),
199 "error chain should mention the non-table project item, got: {chain}"
200 );
201 }
202
203 #[tokio::test]
204 async fn test_write_pyproject_version_non_table_project_leaves_file_untouched() {
205 let temp_dir = TempDir::new().unwrap();
206 let pyproject_toml = temp_dir.path().join("pyproject.toml");
207 let original = "project = 1\n\n[build-system]\nrequires = [\"hatchling\"]\n";
212 fs::write(&pyproject_toml, original).unwrap();
213
214 let err = write_pyproject_version(&pyproject_toml, "1.0.1")
215 .await
216 .expect_err("non-table project item must fail");
217 let chain = format!("{err:#}");
218 assert!(
219 chain.contains("has a non-table [project] item"),
220 "error chain should name the non-table project guard, got: {chain}"
221 );
222 assert!(
223 chain.contains(&pyproject_toml.display().to_string()),
224 "error chain should name the manifest path, got: {chain}"
225 );
226
227 assert_eq!(
230 fs::read(&pyproject_toml).unwrap(),
231 original.as_bytes(),
232 "a rejected bump must leave the manifest byte-identical"
233 );
234 }
235
236 #[tokio::test]
237 async fn test_write_pyproject_version_rejects_dynamic_version_multiline() {
238 let temp_dir = TempDir::new().unwrap();
239 let pyproject_toml = temp_dir.path().join("pyproject.toml");
240 let content = "[project]\ndynamic = [ \"version\" ]\n";
241 fs::write(&pyproject_toml, content).unwrap();
242
243 let err = write_pyproject_version(&pyproject_toml, "2.0.0")
244 .await
245 .expect_err("dynamic version must be rejected");
246 let chain = format!("{err:#}");
247 assert!(
248 chain.contains(&pyproject_toml.display().to_string()),
249 "error chain should name the manifest path, got: {chain}"
250 );
251 assert!(
252 chain.contains("has backend-managed version in project.dynamic"),
253 "error chain should mention project.dynamic, got: {chain}"
254 );
255
256 let after = fs::read(&pyproject_toml).unwrap();
257 assert_eq!(
258 after,
259 content.as_bytes(),
260 "file bytes must be unchanged after rejection"
261 );
262 }
263
264 #[tokio::test]
265 async fn test_write_pyproject_version_rejects_dynamic_version_compact() {
266 let temp_dir = TempDir::new().unwrap();
267 let pyproject_toml = temp_dir.path().join("pyproject.toml");
268 let content = "[project]\ndynamic = [\"version\"]\n";
269 fs::write(&pyproject_toml, content).unwrap();
270
271 let err = write_pyproject_version(&pyproject_toml, "2.0.0")
272 .await
273 .expect_err("dynamic version must be rejected");
274 let chain = format!("{err:#}");
275 assert!(
276 chain.contains(&pyproject_toml.display().to_string()),
277 "error chain should name the manifest path, got: {chain}"
278 );
279 assert!(
280 chain.contains("project.dynamic"),
281 "error chain should mention project.dynamic, got: {chain}"
282 );
283
284 let after = fs::read(&pyproject_toml).unwrap();
285 assert_eq!(
286 after,
287 content.as_bytes(),
288 "file bytes must be unchanged after rejection"
289 );
290 }
291
292 #[tokio::test]
296 async fn test_write_pyproject_version_allows_dynamic_without_version() {
297 let temp_dir = TempDir::new().unwrap();
298 let pyproject_toml = temp_dir.path().join("pyproject.toml");
299 fs::write(
300 &pyproject_toml,
301 "[project]\nname = \"demo\"\nversion = \"1.0.0\"\ndynamic = [\"readme\"]\n",
302 )
303 .unwrap();
304
305 write_pyproject_version(&pyproject_toml, "1.1.0")
306 .await
307 .expect("dynamic without a version entry must still be writable");
308
309 assert_eq!(
310 fs::read_to_string(&pyproject_toml).unwrap(),
311 "[project]\nname = \"demo\"\nversion = \"1.1.0\"\ndynamic = [\"readme\"]\n"
312 );
313 }
314
315 fn round_trip_manifest(version: &str) -> String {
323 format!(
324 concat!(
325 "# demo package manifest - this header comment must survive a bump\n",
326 "\n",
327 "[build-system]\n",
328 "requires = [\"hatchling>=1.18\"]\n",
329 "build-backend = \"hatchling.build\"\n",
330 "\n",
331 "[project]\n",
332 "name = \"demo\"\n",
333 "version = \"{version}\" # bumped by changepacks\n",
334 "dependencies = [\n",
335 " \"httpx>=0.27\",\n",
336 " \"rich>=13\",\n",
337 "]\n",
338 "\n",
339 "[tool.uv.sources]\n",
340 "demo-core = {{ path = \"../core\", editable = true }}\n",
341 ),
342 version = version
343 )
344 }
345
346 #[tokio::test]
352 async fn test_write_pyproject_version_preserves_comments_and_table_order() {
353 let temp_dir = TempDir::new().unwrap();
354 let pyproject_toml = temp_dir.path().join("pyproject.toml");
355 fs::write(&pyproject_toml, round_trip_manifest("1.2.3")).unwrap();
356
357 write_pyproject_version(&pyproject_toml, "2.0.0")
358 .await
359 .expect("a well-formed manifest must be writable");
360
361 assert_eq!(
362 fs::read_to_string(&pyproject_toml).unwrap(),
363 round_trip_manifest("2.0.0"),
364 "only the version literal may change; everything else must be byte-identical"
365 );
366 }
367}