diff --git a/crates/uv-workspace/src/pyproject.rs b/crates/uv-workspace/src/pyproject.rs index 760685117..b9d57e340 100644 --- a/crates/uv-workspace/src/pyproject.rs +++ b/crates/uv-workspace/src/pyproject.rs @@ -30,7 +30,7 @@ pub struct PyProjectToml { pub tool: Option, /// The raw unserialized document. #[serde(skip)] - pub(crate) raw: String, + pub raw: String, } impl PyProjectToml { diff --git a/crates/uv-workspace/src/pyproject_mut.rs b/crates/uv-workspace/src/pyproject_mut.rs index c19f43644..b105c98c1 100644 --- a/crates/uv-workspace/src/pyproject_mut.rs +++ b/crates/uv-workspace/src/pyproject_mut.rs @@ -55,11 +55,6 @@ impl PyProjectTomlMut { }) } - /// Initialize a [`PyProjectToml`] from a [`PyProjectTomlMut`]. - pub fn to_toml(&self) -> Result { - Ok(toml::from_str(&self.doc.to_string()).map_err(Box::new)?) - } - /// Adds a project to the workspace. pub fn add_workspace(&mut self, path: impl AsRef) -> Result<(), Error> { // Get or create `tool.uv.workspace.members`. diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index f44e61847..176f98ade 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -2,9 +2,9 @@ use std::collections::hash_map::Entry; use anyhow::{Context, Result}; use owo_colors::OwoColorize; -use rustc_hash::{FxBuildHasher, FxHashMap}; - use pep508_rs::{ExtraName, Requirement, VersionOrUrl}; +use rustc_hash::{FxBuildHasher, FxHashMap}; +use tracing::debug; use uv_auth::store_credentials_from_url; use uv_cache::Cache; use uv_client::{BaseClientBuilder, Connectivity, FlatIndexClient, RegistryClientBuilder}; @@ -247,7 +247,14 @@ pub(crate) async fn add( } // Save the modified `pyproject.toml`. - fs_err::write(project.root().join("pyproject.toml"), pyproject.to_string())?; + let mut modified = false; + let content = pyproject.to_string(); + if content == existing.raw { + debug!("No changes to `pyproject.toml`; skipping update"); + } else { + fs_err::write(project.root().join("pyproject.toml"), &content)?; + modified = true; + } // If `--frozen`, exit early. There's no reason to lock and sync, and we don't need a `uv.lock` // to exist at all. @@ -258,7 +265,7 @@ pub(crate) async fn add( // Update the `pypackage.toml` in-memory. let project = project .clone() - .with_pyproject_toml(pyproject.to_toml()?) + .with_pyproject_toml(toml::from_str(&content)?) .context("Failed to update `pyproject.toml`")?; // Lock and sync the environment, if necessary. @@ -286,8 +293,10 @@ pub(crate) async fn add( let report = miette::Report::new(WithHelp { header, cause: err, help: Some("If this is intentional, run `uv add --frozen` to skip the lock and sync steps.") }); anstream::eprint!("{report:?}"); - // Revert the changes to the `pyproject.toml`. - fs_err::write(project.root().join("pyproject.toml"), existing)?; + // Revert the changes to the `pyproject.toml`, if necessary. + if modified { + fs_err::write(project.root().join("pyproject.toml"), existing)?; + } return Ok(ExitStatus::Failure); } @@ -361,7 +370,9 @@ pub(crate) async fn add( modified = true; } - // Save the modified `pyproject.toml`. + // Save the modified `pyproject.toml`. No need to check for changes in the underlying + // string content, since the above loop _must_ change an empty specifier to a non-empty + // specifier. if modified { fs_err::write(project.root().join("pyproject.toml"), pyproject.to_string())?; } diff --git a/crates/uv/tests/edit.rs b/crates/uv/tests/edit.rs index 80156f857..914f5ef99 100644 --- a/crates/uv/tests/edit.rs +++ b/crates/uv/tests/edit.rs @@ -2829,3 +2829,86 @@ fn add_virtual() -> Result<()> { Ok(()) } + +/// Add the same requirement multiple times. +#[test] +fn add_repeat() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + # ... + requires-python = ">=3.12" + dependencies = [] + "#})?; + + uv_snapshot!(context.filters(), context.add(&["anyio"]), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv add` is experimental and may change without warning + Resolved 4 packages in [TIME] + Prepared 4 packages in [TIME] + Installed 4 packages in [TIME] + + anyio==4.3.0 + + idna==3.6 + + project==0.1.0 (from file://[TEMP_DIR]/) + + sniffio==1.3.1 + "###); + + let pyproject_toml = fs_err::read_to_string(context.temp_dir.join("pyproject.toml"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r###" + [project] + name = "project" + version = "0.1.0" + # ... + requires-python = ">=3.12" + dependencies = [ + "anyio>=4.3.0", + ] + "### + ); + }); + + uv_snapshot!(context.filters(), context.add(&["anyio"]), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv add` is experimental and may change without warning + Resolved 4 packages in [TIME] + Audited 4 packages in [TIME] + "###); + + let pyproject_toml = fs_err::read_to_string(context.temp_dir.join("pyproject.toml"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r###" + [project] + name = "project" + version = "0.1.0" + # ... + requires-python = ">=3.12" + dependencies = [ + "anyio>=4.3.0", + ] + "### + ); + }); + + Ok(()) +}