diff --git a/crates/uv-workspace/src/pyproject_mut.rs b/crates/uv-workspace/src/pyproject_mut.rs index 3e083f55af14..cfb084fea45f 100644 --- a/crates/uv-workspace/src/pyproject_mut.rs +++ b/crates/uv-workspace/src/pyproject_mut.rs @@ -3,7 +3,7 @@ use std::str::FromStr; use std::{fmt, mem}; use pep440_rs::{Version, VersionSpecifier, VersionSpecifiers}; -use pep508_rs::{ExtraName, PackageName, Requirement, VersionOrUrl}; +use pep508_rs::{ExtraName, MarkerTree, PackageName, Requirement, VersionOrUrl}; use thiserror::Error; use toml_edit::{Array, DocumentMut, Item, RawString, Table, TomlError, Value}; use uv_fs::PortablePath; @@ -343,7 +343,7 @@ impl PyProjectTomlMut { } /// Removes all occurrences of dependencies with the given name. - pub fn remove_dependency(&mut self, req: &PackageName) -> Result, Error> { + pub fn remove_dependency(&mut self, name: &PackageName) -> Result, Error> { // Try to get `project.dependencies`. let Some(dependencies) = self .doc_mut()? @@ -354,14 +354,14 @@ impl PyProjectTomlMut { return Ok(Vec::new()); }; - let requirements = remove_dependency(req, dependencies); - self.remove_source(req)?; + let requirements = remove_dependency(name, dependencies); + self.remove_source(name)?; Ok(requirements) } /// Removes all occurrences of development dependencies with the given name. - pub fn remove_dev_dependency(&mut self, req: &PackageName) -> Result, Error> { + pub fn remove_dev_dependency(&mut self, name: &PackageName) -> Result, Error> { // Try to get `tool.uv.dev-dependencies`. let Some(dev_dependencies) = self .doc @@ -378,8 +378,8 @@ impl PyProjectTomlMut { return Ok(Vec::new()); }; - let requirements = remove_dependency(req, dev_dependencies); - self.remove_source(req)?; + let requirements = remove_dependency(name, dev_dependencies); + self.remove_source(name)?; Ok(requirements) } @@ -387,7 +387,7 @@ impl PyProjectTomlMut { /// Removes all occurrences of optional dependencies in the group with the given name. pub fn remove_optional_dependency( &mut self, - req: &PackageName, + name: &PackageName, group: &ExtraName, ) -> Result, Error> { // Try to get `project.optional-dependencies.`. @@ -403,8 +403,8 @@ impl PyProjectTomlMut { return Ok(Vec::new()); }; - let requirements = remove_dependency(req, optional_dependencies); - self.remove_source(req)?; + let requirements = remove_dependency(name, optional_dependencies); + self.remove_source(name)?; Ok(requirements) } @@ -434,13 +434,17 @@ impl PyProjectTomlMut { /// /// This method searches `project.dependencies`, `tool.uv.dev-dependencies`, and /// `tool.uv.optional-dependencies`. - pub fn find_dependency(&self, name: &PackageName) -> Vec { + pub fn find_dependency( + &self, + name: &PackageName, + marker: Option<&MarkerTree>, + ) -> Vec { let mut types = Vec::new(); if let Some(project) = self.doc.get("project").and_then(Item::as_table) { // Check `project.dependencies`. if let Some(dependencies) = project.get("dependencies").and_then(Item::as_array) { - if !find_dependencies(name, dependencies).is_empty() { + if !find_dependencies(name, marker, dependencies).is_empty() { types.push(DependencyType::Production); } } @@ -458,7 +462,7 @@ impl PyProjectTomlMut { continue; }; - if !find_dependencies(name, dependencies).is_empty() { + if !find_dependencies(name, marker, dependencies).is_empty() { types.push(DependencyType::Optional(extra)); } } @@ -475,7 +479,7 @@ impl PyProjectTomlMut { .and_then(|tool| tool.get("dev-dependencies")) .and_then(Item::as_array) { - if !find_dependencies(name, dev_dependencies).is_empty() { + if !find_dependencies(name, marker, dev_dependencies).is_empty() { types.push(DependencyType::Dev); } } @@ -500,7 +504,7 @@ pub fn add_dependency( has_source: bool, ) -> Result { // Find matching dependencies. - let mut to_replace = find_dependencies(&req.name, deps); + let mut to_replace = find_dependencies(&req.name, Some(&req.marker), deps); match to_replace.as_slice() { [] => { deps.push(req.to_string()); @@ -545,9 +549,9 @@ fn update_requirement(old: &mut Requirement, new: &Requirement, has_source: bool } /// Removes all occurrences of dependencies with the given name from the given `deps` array. -fn remove_dependency(req: &PackageName, deps: &mut Array) -> Vec { +fn remove_dependency(name: &PackageName, deps: &mut Array) -> Vec { // Remove matching dependencies. - let removed = find_dependencies(req, deps) + let removed = find_dependencies(name, None, deps) .into_iter() .rev() // Reverse to preserve indices as we remove them. .filter_map(|(i, _)| { @@ -566,11 +570,15 @@ fn remove_dependency(req: &PackageName, deps: &mut Array) -> Vec { /// Returns a `Vec` containing the all dependencies with the given name, along with their positions /// in the array. -fn find_dependencies(name: &PackageName, deps: &Array) -> Vec<(usize, Requirement)> { +fn find_dependencies( + name: &PackageName, + marker: Option<&MarkerTree>, + deps: &Array, +) -> Vec<(usize, Requirement)> { let mut to_replace = Vec::new(); for (i, dep) in deps.iter().enumerate() { if let Some(req) = dep.as_str().and_then(try_parse_requirement) { - if req.name == *name { + if marker.map_or(true, |m| *m == req.marker) && *name == req.name { to_replace.push((i, req)); } } diff --git a/crates/uv/src/commands/project/remove.rs b/crates/uv/src/commands/project/remove.rs index f9ac64fffe48..e5f01e582c02 100644 --- a/crates/uv/src/commands/project/remove.rs +++ b/crates/uv/src/commands/project/remove.rs @@ -225,7 +225,7 @@ enum Target { /// This is useful when a dependency of the user-specified type was not found, but it may be present /// elsewhere. fn warn_if_present(name: &PackageName, pyproject: &PyProjectTomlMut) { - for dep_ty in pyproject.find_dependency(name) { + for dep_ty in pyproject.find_dependency(name, None) { match dep_ty { DependencyType::Production => { warn_user!("`{name}` is a production dependency"); @@ -235,7 +235,7 @@ fn warn_if_present(name: &PackageName, pyproject: &PyProjectTomlMut) { } DependencyType::Optional(group) => { warn_user!( - "`{name}` is an optional dependency; try calling `uv remove --optional {group}`" + "`{name}` is an optional dependency; try calling `uv remove --optional {group}`", ); } } diff --git a/crates/uv/tests/edit.rs b/crates/uv/tests/edit.rs index 383e52dbfd82..f969e1d9c1d4 100644 --- a/crates/uv/tests/edit.rs +++ b/crates/uv/tests/edit.rs @@ -1770,7 +1770,8 @@ fn update() -> Result<()> { version = "0.1.0" requires-python = ">=3.12" dependencies = [ - "requests[security,socks,use-chardet-on-py3]==2.31.0 ; python_full_version >= '3.8'", + "requests[security]==2.31.0", + "requests[socks,use-chardet-on-py3]>=2.31.0 ; python_full_version >= '3.8'", ] "### ); @@ -1808,7 +1809,8 @@ fn update() -> Result<()> { version = "0.1.0" requires-python = ">=3.12" dependencies = [ - "requests[security,socks,use-chardet-on-py3]==2.31.0 ; python_full_version >= '3.8'", + "requests[security]==2.31.0", + "requests[socks,use-chardet-on-py3]>=2.31.0 ; python_full_version >= '3.8'", ] [tool.uv.sources] @@ -1890,7 +1892,10 @@ fn update() -> Result<()> { ] [package.metadata] - requires-dist = [{ name = "requests", extras = ["security", "socks", "use-chardet-on-py3"], marker = "python_full_version >= '3.8'", git = "https://github.com/psf/requests?tag=v2.32.3" }] + requires-dist = [ + { name = "requests", extras = ["security"], git = "https://github.com/psf/requests?tag=v2.32.3" }, + { name = "requests", extras = ["socks", "use-chardet-on-py3"], marker = "python_full_version >= '3.8'", git = "https://github.com/psf/requests?tag=v2.32.3" }, + ] [[package]] name = "pysocks" @@ -1946,6 +1951,241 @@ fn update() -> Result<()> { Ok(()) } +/// Add and update a requirement, with different markers +#[test] +fn add_update_marker() -> 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.8" + dependencies = [ + "requests>=2.30; python_version >= '3.11'" + ] + "#})?; + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv lock` is experimental and may change without warning + Resolved 6 packages in [TIME] + "###); + + uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv sync` is experimental and may change without warning + Prepared 6 packages in [TIME] + Installed 6 packages in [TIME] + + certifi==2024.2.2 + + charset-normalizer==3.3.2 + + idna==3.6 + + project==0.1.0 (from file://[TEMP_DIR]/) + + requests==2.31.0 + + urllib3==2.2.1 + "###); + + // Restrict the `requests` version for Python <3.11 + uv_snapshot!(context.filters(), context.add(&["requests>=2.0,<2.29; python_version < '3.11'"]), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv add` is experimental and may change without warning + Resolved 8 packages in [TIME] + Prepared 1 package in [TIME] + Uninstalled 1 package in [TIME] + Installed 1 package in [TIME] + - project==0.1.0 (from file://[TEMP_DIR]/) + + project==0.1.0 (from file://[TEMP_DIR]/) + "###); + + let pyproject_toml = fs_err::read_to_string(context.temp_dir.join("pyproject.toml"))?; + + // Should add a new line for the dependency since the marker does not match an existing one + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r###" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.8" + dependencies = [ + "requests>=2.30; python_version >= '3.11'", + "requests>=2.0,<2.29 ; python_full_version < '3.11'", + ] + "### + ); + }); + + // Change the restricted `requests` version for Python <3.11 + uv_snapshot!(context.filters(), context.add(&["requests>=2.0,<2.20; python_version < '3.11'"]), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv add` is experimental and may change without warning + Resolved 10 packages in [TIME] + Prepared 1 package in [TIME] + Uninstalled 1 package in [TIME] + Installed 1 package in [TIME] + - project==0.1.0 (from file://[TEMP_DIR]/) + + project==0.1.0 (from file://[TEMP_DIR]/) + "###); + + let pyproject_toml = fs_err::read_to_string(context.temp_dir.join("pyproject.toml"))?; + + // Should mutate the existing dependency since the marker matches + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r###" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.8" + dependencies = [ + "requests>=2.30; python_version >= '3.11'", + "requests>=2.0,<2.20 ; python_full_version < '3.11'", + ] + "### + ); + }); + + // Restrict the `requests` version on Windows and Python >3.11 + uv_snapshot!(context.filters(), context.add(&["requests>=2.31 ; sys_platform == 'win32' and python_version > '3.11'"]), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv add` is experimental and may change without warning + Resolved 8 packages in [TIME] + Prepared 3 packages in [TIME] + Uninstalled 3 packages in [TIME] + Installed 3 packages in [TIME] + - idna==3.6 + + idna==2.7 + - project==0.1.0 (from file://[TEMP_DIR]/) + + project==0.1.0 (from file://[TEMP_DIR]/) + - urllib3==2.2.1 + + urllib3==1.23 + "###); + + let pyproject_toml = fs_err::read_to_string(context.temp_dir.join("pyproject.toml"))?; + + // Should add a new line for the dependency since the marker does not match an existing one + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r###" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.8" + dependencies = [ + "requests>=2.30; python_version >= '3.11'", + "requests>=2.0,<2.20 ; python_full_version < '3.11'", + "requests>=2.31 ; python_full_version >= '3.12' and sys_platform == 'win32'", + ] + "### + ); + }); + + // Restrict the `requests` version on Windows + uv_snapshot!(context.filters(), context.add(&["requests>=2.10 ; sys_platform == 'win32'"]), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv add` is experimental and may change without warning + Resolved 8 packages in [TIME] + Prepared 1 package in [TIME] + Uninstalled 1 package in [TIME] + Installed 1 package in [TIME] + - project==0.1.0 (from file://[TEMP_DIR]/) + + project==0.1.0 (from file://[TEMP_DIR]/) + "###); + + let pyproject_toml = fs_err::read_to_string(context.temp_dir.join("pyproject.toml"))?; + + // Should add a new line for the dependency since the marker does not exactly match an existing + // one — although it is a subset of the existing marker. + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r###" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.8" + dependencies = [ + "requests>=2.30; python_version >= '3.11'", + "requests>=2.0,<2.20 ; python_full_version < '3.11'", + "requests>=2.31 ; python_full_version >= '3.12' and sys_platform == 'win32'", + "requests>=2.10 ; sys_platform == 'win32'", + ] + "### + ); + }); + + // Remove `requests` + uv_snapshot!(context.filters(), context.remove(&["requests"]), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv remove` is experimental and may change without warning + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Uninstalled 6 packages in [TIME] + Installed 1 package in [TIME] + - certifi==2024.2.2 + - charset-normalizer==3.3.2 + - idna==2.7 + - project==0.1.0 (from file://[TEMP_DIR]/) + + project==0.1.0 (from file://[TEMP_DIR]/) + - requests==2.31.0 + - urllib3==1.23 + "###); + + let pyproject_toml = fs_err::read_to_string(context.temp_dir.join("pyproject.toml"))?; + + // Should remove all variants of `requests` + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r###" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.8" + dependencies = [] + "### + ); + }); + + Ok(()) +} + #[test] #[cfg(feature = "git")] fn update_source_replace_url() -> Result<()> {