diff --git a/crates/uv-workspace/src/pyproject_mut.rs b/crates/uv-workspace/src/pyproject_mut.rs index 67b3f9ae3b61..d87a0343da3a 100644 --- a/crates/uv-workspace/src/pyproject_mut.rs +++ b/crates/uv-workspace/src/pyproject_mut.rs @@ -131,6 +131,7 @@ impl PyProjectTomlMut { }; Ok(doc) } + /// Adds a dependency to `project.dependencies`. /// /// Returns `true` if the dependency was added, `false` if it was updated. @@ -431,7 +432,11 @@ impl PyProjectTomlMut { .as_table_mut() .ok_or(Error::MalformedSources)?; + if let Some(key) = find_source(name, sources) { + sources.remove(&key); + } add_source(name, source, sources)?; + Ok(()) } @@ -532,7 +537,9 @@ impl PyProjectTomlMut { .map(|sources| sources.as_table_mut().ok_or(Error::MalformedSources)) .transpose()? { - sources.remove(name.as_ref()); + if let Some(key) = find_source(name, sources) { + sources.remove(&key); + } } Ok(()) @@ -766,6 +773,16 @@ fn find_dependencies( to_replace } +/// Returns the key in `tool.uv.sources` that matches the given package name. +fn find_source(name: &PackageName, sources: &Table) -> Option { + for (key, _) in sources { + if PackageName::from_str(key).is_ok_and(|ref key| key == name) { + return Some(key.to_string()); + } + } + None +} + // Add a source to `tool.uv.sources`. fn add_source(req: &PackageName, source: &Source, sources: &mut Table) -> Result<(), Error> { // Serialize as an inline table. diff --git a/crates/uv/tests/it/edit.rs b/crates/uv/tests/it/edit.rs index 33fc185f3cb9..080659d6973e 100644 --- a/crates/uv/tests/it/edit.rs +++ b/crates/uv/tests/it/edit.rs @@ -2717,6 +2717,175 @@ fn update_source_replace_url() -> Result<()> { ); }); + // Change the source again. The existing source should be replaced. + uv_snapshot!(context.filters(), context.add().arg("requests @ git+https://github.com/psf/requests").arg("--tag=v2.32.2"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 6 packages in [TIME] + Prepared 2 packages in [TIME] + Uninstalled 2 packages in [TIME] + Installed 2 packages in [TIME] + ~ project==0.1.0 (from file://[TEMP_DIR]/) + - requests==2.32.3 (from git+https://github.com/psf/requests@0e322af87745eff34caffe4df68456ebc20d9068) + + requests==2.32.2 (from git+https://github.com/psf/requests@88dce9d854797c05d0ff296b70e0430535ef8aaf) + "###); + + let pyproject_toml = context.read("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 = [ + "requests[security]", + ] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + + [tool.uv.sources] + requests = { git = "https://github.com/psf/requests", tag = "v2.32.2" } + "### + ); + }); + + Ok(()) +} + +/// If a source defined in `tool.uv.sources` but its name is not normalized, `uv add` should not +/// add the same source again. +#[test] +#[cfg(feature = "git")] +fn add_non_normalized_source() -> 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-public-pypackage" + ] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + + [tool.uv.sources] + uv_public_pypackage = { git = "https://github.com/astral-test/uv-public-pypackage", tag = "0.0.1" } + "#})?; + + uv_snapshot!(context.filters(), context.add().arg("uv-public-pypackage @ git+https://github.com/astral-test/uv-public-pypackage@0.0.1"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Prepared 2 packages in [TIME] + Installed 2 packages in [TIME] + + project==0.1.0 (from file://[TEMP_DIR]/) + + uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage@0dacfd662c64cb4ceb16e6cf65a157a8b715b979) + "###); + + let pyproject_toml = context.read("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 = [ + "uv-public-pypackage", + ] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + + [tool.uv.sources] + uv-public-pypackage = { git = "https://github.com/astral-test/uv-public-pypackage", rev = "0.0.1" } + "### + ); + }); + + Ok(()) +} + +/// If a source defined in `tool.uv.sources` but its name is not normalized, `uv remove` should +/// remove the source. +#[test] +#[cfg(feature = "git")] +fn remove_non_normalized_source() -> 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-public-pypackage" + ] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + + [tool.uv.sources] + uv_public_pypackage = { git = "https://github.com/astral-test/uv-public-pypackage", tag = "0.0.1" } + "#})?; + + uv_snapshot!(context.filters(), context.remove().arg("uv-public-pypackage"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + project==0.1.0 (from file://[TEMP_DIR]/) + "###); + + let pyproject_toml = context.read("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 = [] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + + [tool.uv.sources] + "### + ); + }); + Ok(()) }