Skip to main content

changepacks_python/
lib.rs

1//! # changepacks-python
2//!
3//! Python project support for changepacks.
4//!
5//! Implements project discovery and version management for pyproject.toml files. Parses
6//! TOML using `toml_edit` for non-destructive formatting preservation when updating
7//! versions. Supports both single packages and workspace configurations.
8
9pub mod finder;
10pub mod package;
11pub mod workspace;
12
13pub use finder::PythonProjectFinder;
14
15/// Default publish command for Python projects. Shared by `PythonPackage`
16/// and `PythonWorkspace` so a single edit here updates both trait impls.
17pub(crate) const PUBLISH_COMMAND: &str = "uv publish";
18
19/// Default dry-run publish command for Python projects.
20/// `uv publish --dry-run` is `uv`'s built-in non-mutating verification;
21/// users can override via `publishDryRun` in `.changepacks/config.json`.
22pub(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
31/// Compute the next version for `update_type`, write it into the
32/// `pyproject.toml` at `path`, and store it back into `version`.
33///
34/// `PythonPackage::update_version` and `PythonWorkspace::update_version` had
35/// byte-identical bodies; this holds that body once so the two trait impls
36/// cannot drift. Both `update_version` signatures stay hand-written rather
37/// than macro-generated: `async_trait` rewrites the `impl` block before a
38/// `macro_rules!` body expands, so a macro would emit a plain `async fn` that
39/// no longer matches the desugared trait signature (E0195) — the same reason
40/// documented at `crates/java/src/package.rs:65-74`.
41///
42/// # Errors
43/// Returns an error when semver calculation fails or the manifest write fails.
44pub(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
55/// Read and parse a pyproject.toml file, returning both the raw content
56/// (for trailing-newline preservation) and the parsed TOML document.
57///
58/// The read-then-parse-with-context sequence lives in
59/// [`changepacks_utils::read_and_parse`] — the mirror of
60/// [`changepacks_utils::write_finalized`] — so only the `pyproject.toml` label
61/// and the `toml_edit` parser stay here.
62///
63/// # Errors
64/// Returns error if the file cannot be read or is not valid TOML.
65pub(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
69/// Update `pyproject.toml` at `path` to set `[project].version` to
70/// `new_version`, preserving the file's complete trailing-whitespace shape and
71/// its TOML formatting (via `toml_edit`).
72///
73/// Shared by `PythonPackage::update_version` and
74/// `PythonWorkspace::update_version` so both paths emit byte-identical output.
75///
76/// The whole read → table-like guard → validation → `[project]` creation →
77/// decor-preserving assign → trailing-whitespace-preserving write pipeline
78/// lives in [`changepacks_utils::write_toml_table_version`], because
79/// `changepacks-rust`'s `write_cargo_package_version` was the same skeleton
80/// modulo the manifest label, the table key, and the `project.dynamic` guard
81/// below, and `crates/AGENTS.md` forbids importing one language crate into
82/// another. This wrapper stays so the `pyproject.toml` key/label pair and the
83/// Python-only rule are bound in ONE place and every call site inside this
84/// crate is unchanged.
85///
86/// An empty `[project]` table is created if missing — needed for workspace
87/// roots that only declare `[tool.uv.workspace]` and for `[build-system]`-only
88/// package manifests (a valid PEP 517 shape). The explicit `Table::new()` in
89/// the shared helper matters: plain `doc["project"]["version"] = ...`
90/// auto-creates an INLINE table (`project = { version = ... }`) at the top of
91/// the document instead of a proper `[project]` header.
92///
93/// The `project.dynamic` validator runs AFTER the table-like guard and BEFORE
94/// any mutation, exactly as the previous inline body did, so a manifest whose
95/// version is owned by the build backend is rejected without ever being
96/// rewritten — and a scalar `project` key still reports the table-like error
97/// first.
98///
99/// The version assignment goes through
100/// [`changepacks_utils::assign_preserving_decor`], which carries the existing
101/// value's [`toml_edit::Decor`] across the write. Assigning a freshly built
102/// value replaces the whole `Item`, and a fresh value carries default (empty)
103/// decor, so without that the surrounding trivia — most visibly an
104/// end-of-line comment such as `version = "1.2.3" # pinned` — would be
105/// silently deleted from the user's manifest by a routine version bump.
106///
107/// # Errors
108/// Returns error if the file cannot be read, is not valid TOML, `project` is
109/// present but not table-like, the version is backend-managed via
110/// `project.dynamic`, or the write fails.
111pub(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        // The read succeeds (readonly still permits reads); it is the
164        // write-back that must fail, so flip the readonly bit after seeding.
165        test_support::set_readonly(&pyproject_toml, true);
166
167        // A NEW version guarantees the write is actually attempted against the
168        // readonly file rather than being short-circuited as an unchanged no-op.
169        let result = write_pyproject_version(&pyproject_toml, "2.0.0").await;
170
171        // Restore write permission BEFORE asserting so `TempDir` cleanup
172        // succeeds even if an assertion panics.
173        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        // A scalar top-level `project` key. The sibling test above pins the
208        // ERROR TEXT; this one pins the guard's actual reason for existing —
209        // it must reject BEFORE the `pyproject_toml["project"]["version"] = ...`
210        // assignment ever runs, so the manifest on disk is never clobbered.
211        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        // Byte-for-byte, not line-for-line: a partial or reformatted write is
228        // exactly the manifest destruction the guard prevents.
229        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    /// Pins the boundary of the `project.dynamic` guard: only the literal
293    /// `"version"` entry hands version ownership to the build backend, so a
294    /// `dynamic` array listing anything else must still be bumped normally.
295    #[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    /// Renders a realistically formatted `pyproject.toml` at `version`.
316    ///
317    /// Every construct here is one a re-serializing TOML writer silently
318    /// normalizes away: a header comment, `[build-system]` declared BEFORE
319    /// `[project]` (non-alphabetical, non-canonical table order), an
320    /// end-of-line comment on the version line, a multi-line array with a
321    /// trailing comma and an inline table with custom spacing.
322    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    /// Format preservation is a hard project constraint, but until now only
347    /// trailing whitespace was pinned. This asserts COMPLETE-FILE equality
348    /// (not a `contains` check) so any reformatting `toml_edit` performs -
349    /// dropped comment, reordered table, collapsed array or inline table -
350    /// fails the test rather than silently rewriting a user's manifest.
351    #[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}