diff --git a/Cargo.lock b/Cargo.lock index f247509778bc..1419e34e2b1c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5281,6 +5281,7 @@ dependencies = [ "fs-err", "glob", "insta", + "itertools 0.13.0", "pep440_rs", "pep508_rs", "pypi-types", diff --git a/crates/uv-workspace/Cargo.toml b/crates/uv-workspace/Cargo.toml index 7b5a40473cfa..909e682b3712 100644 --- a/crates/uv-workspace/Cargo.toml +++ b/crates/uv-workspace/Cargo.toml @@ -35,6 +35,7 @@ toml = { workspace = true } toml_edit = { workspace = true } tracing = { workspace = true } url = { workspace = true } +itertools = { workspace = true } [dev-dependencies] insta = { version = "1.39.0", features = ["filters", "json", "redactions"] } diff --git a/crates/uv-workspace/src/pyproject_mut.rs b/crates/uv-workspace/src/pyproject_mut.rs index 90b6d19919e7..38e7fa81d5d2 100644 --- a/crates/uv-workspace/src/pyproject_mut.rs +++ b/crates/uv-workspace/src/pyproject_mut.rs @@ -1,12 +1,11 @@ +use itertools::Itertools; +use pep440_rs::{Version, VersionSpecifier, VersionSpecifiers}; +use pep508_rs::{ExtraName, MarkerTree, PackageName, Requirement, VersionOrUrl}; use std::path::Path; use std::str::FromStr; use std::{fmt, mem}; - use thiserror::Error; use toml_edit::{Array, DocumentMut, Item, RawString, Table, TomlError, Value}; - -use pep440_rs::{Version, VersionSpecifier, VersionSpecifiers}; -use pep508_rs::{ExtraName, MarkerTree, PackageName, Requirement, VersionOrUrl}; use uv_fs::PortablePath; use crate::pyproject::{DependencyType, Source}; @@ -522,13 +521,53 @@ pub fn add_dependency( deps: &mut Array, has_source: bool, ) -> Result { - // Find matching dependencies. let mut to_replace = find_dependencies(&req.name, Some(&req.marker), deps); + match to_replace.as_slice() { [] => { - deps.push(req.to_string()); + // Determine if the dependency list is sorted prior to + // adding the new dependency; the new dependency list + // will be sorted only when the original list is sorted + // so that user's custom dependency ordering is preserved. + // Additionally, if the table is invalid (i.e. contains non-string values) + // we still treat it as unsorted for the sake of simplicity. + let sorted = deps.iter().all(toml_edit::Value::is_str) + && deps + .iter() + .tuple_windows() + .all(|(a, b)| a.as_str() <= b.as_str()); + + let req_string = req.to_string(); + let index = if sorted { + deps.iter() + .position(|d: &Value| d.as_str() > Some(req_string.as_str())) + .unwrap_or(deps.len()) + } else { + deps.len() + }; + + deps.insert(index, req_string); + // `reformat_array_multiline` uses the indentation of the first dependency entry. + // Therefore, we retrieve the indentation of the first dependency entry and apply it to + // the new entry. Note that it is only necessary if the newly added dependency is going + // to be the first in the list _and_ the dependency list was not empty prior to adding + // the new dependency. + if deps.len() > 1 && index == 0 { + let prefix = deps + .clone() + .get(index + 1) + .unwrap() + .decor() + .prefix() + .unwrap() + .clone(); + + deps.get_mut(index).unwrap().decor_mut().set_prefix(prefix); + } + reformat_array_multiline(deps); - Ok(ArrayEdit::Add(deps.len() - 1)) + + Ok(ArrayEdit::Add(index)) } [_] => { let (i, mut old_req) = to_replace.remove(0); diff --git a/crates/uv/tests/edit.rs b/crates/uv/tests/edit.rs index 6c30c2ce91a1..930ba3ed773f 100644 --- a/crates/uv/tests/edit.rs +++ b/crates/uv/tests/edit.rs @@ -2310,8 +2310,8 @@ fn add_update_marker() -> Result<()> { 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'", + "requests>=2.30; python_version >= '3.11'", ] [build-system] @@ -2348,8 +2348,8 @@ fn add_update_marker() -> Result<()> { 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.30; python_version >= '3.11'", ] [build-system] @@ -2390,8 +2390,8 @@ fn add_update_marker() -> Result<()> { 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.30; python_version >= '3.11'", "requests>=2.31 ; python_full_version >= '3.12' and sys_platform == 'win32'", ] @@ -2430,10 +2430,10 @@ fn add_update_marker() -> Result<()> { 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'", + "requests>=2.30; python_version >= '3.11'", + "requests>=2.31 ; python_full_version >= '3.12' and sys_platform == 'win32'", ] [build-system] @@ -3848,8 +3848,8 @@ fn add_requirements_file() -> Result<()> { version = "0.1.0" requires-python = ">=3.12" dependencies = [ - "flask==2.3.2", "anyio", + "flask==2.3.2", ] [build-system] @@ -3932,9 +3932,9 @@ fn add_script() -> Result<()> { # /// script # requires-python = ">=3.11" # dependencies = [ + # "anyio", # "requests<3", # "rich", - # "anyio", # ] # /// @@ -3984,8 +3984,8 @@ fn add_script_without_metadata_table() -> Result<()> { # /// script # requires-python = ">=3.12" # dependencies = [ - # "rich", # "requests<3", + # "rich", # ] # /// import requests @@ -4036,8 +4036,8 @@ fn add_script_without_metadata_table_with_shebang() -> Result<()> { # /// script # requires-python = ">=3.12" # dependencies = [ - # "rich", # "requests<3", + # "rich", # ] # /// import requests @@ -4092,8 +4092,8 @@ fn add_script_with_metadata_table_and_shebang() -> Result<()> { # /// script # requires-python = ">=3.12" # dependencies = [ - # "rich", # "requests<3", + # "rich", # ] # /// import requests @@ -4143,8 +4143,8 @@ fn add_script_without_metadata_table_with_docstring() -> Result<()> { # /// script # requires-python = ">=3.12" # dependencies = [ - # "rich", # "requests<3", + # "rich", # ] # /// """This is a script.""" @@ -4410,3 +4410,115 @@ fn fail_to_add_revert_project() -> Result<()> { Ok(()) } + +/// Ensure that the added dependencies are sorted +/// if the dependency list was already sorted prior to adding the new one. +#[test] +fn sorted_dependencies() -> 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" + description = "Add your description here" + requires-python = ">=3.12" + dependencies = [ + "CacheControl[filecache]>=0.14,<0.15", + "mwparserfromhell", + "pywikibot", + "sentry-sdk", + "yarl", + ] + "#})?; + + uv_snapshot!(context.filters(), context.add(&["pydantic"]).arg("--frozen"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + "###); + + 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" + description = "Add your description here" + requires-python = ">=3.12" + dependencies = [ + "CacheControl[filecache]>=0.14,<0.15", + "mwparserfromhell", + "pydantic", + "pywikibot", + "sentry-sdk", + "yarl", + ] + "### + ); + }); + Ok(()) +} + +/// Ensure that the custom ordering of the dependencies is preserved +/// after adding a package. +#[test] +fn custom_dependencies() -> 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" + description = "Add your description here" + requires-python = ">=3.12" + dependencies = [ + "yarl", + "CacheControl[filecache]>=0.14,<0.15", + "mwparserfromhell", + "pywikibot", + "sentry-sdk", + ] + "#})?; + + uv_snapshot!(context.filters(), context.add(&["pydantic"]).arg("--frozen"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + "###); + + 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" + description = "Add your description here" + requires-python = ">=3.12" + dependencies = [ + "yarl", + "CacheControl[filecache]>=0.14,<0.15", + "mwparserfromhell", + "pywikibot", + "sentry-sdk", + "pydantic", + ] + "### + ); + }); + Ok(()) +}