Preserve order of dependencies which are sorted naively (#13334)

## Summary

The prior implementation only looks for dependencies which are sorted by
name then specifier.

I knew uv was meant to preserve sorted dependencies, but it never seemed
to work for me.

I've always used the "sort lines" feature of PyCharm/Sublime to sort
these lists, and I guess I'm not the only one. In such a case,
`flask-wtf>=1.2.1` is sorted before `flask>=3.0.2`.

After digging into the code I realised what was happening, hence this
merge request.

Maybe there's a tool I'm not aware of that people are using to sort
dependencies "properly", or are doing it by hand, but I think this is
worth supporting.

Relevant issues: https://github.com/astral-sh/uv/issues/9076,
https://github.com/astral-sh/uv/issues/10738

## Test Plan

`cargo test`
This commit is contained in:
Frazer McLean 2025-05-08 00:18:20 +02:00 committed by GitHub
parent 3c413f74b9
commit d242c47821
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 151 additions and 0 deletions

View file

@ -1009,8 +1009,12 @@ pub fn add_dependency(
enum Sort {
/// The list is sorted in a case-insensitive manner.
CaseInsensitive,
/// The list is sorted naively in a case-insensitive manner.
CaseInsensitiveNaive,
/// The list is sorted in a case-sensitive manner.
CaseSensitive,
/// The list is sorted naively in a case-sensitive manner.
CaseSensitiveNaive,
/// The list is unsorted.
Unsorted,
}
@ -1029,6 +1033,13 @@ pub fn add_dependency(
)
}
/// Naively compare two [`Value`] requirements case-insensitively.
fn case_insensitive_naive(a: &Value, b: &Value) -> Ordering {
a.as_str()
.map(str::to_lowercase)
.cmp(&b.as_str().map(str::to_lowercase))
}
/// Compare two [`Value`] requirements case-sensitively.
fn case_sensitive(a: &Value, b: &Value) -> Ordering {
a.as_str()
@ -1036,6 +1047,11 @@ pub fn add_dependency(
.cmp(&b.as_str().map(split_specifiers))
}
/// Naively compare two [`Value`] requirements case-sensitively.
fn case_sensitive_naive(a: &Value, b: &Value) -> Ordering {
a.as_str().cmp(&b.as_str())
}
// 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
@ -1057,6 +1073,17 @@ pub fn add_dependency(
matches!(case_sensitive(a, b), Ordering::Less | Ordering::Equal)
}) {
Some(Sort::CaseSensitive)
} else if deps.iter().tuple_windows().all(|(a, b)| {
matches!(
case_insensitive_naive(a, b),
Ordering::Less | Ordering::Equal
)
}) {
Some(Sort::CaseInsensitiveNaive)
} else if deps.iter().tuple_windows().all(|(a, b)| {
matches!(case_sensitive_naive(a, b), Ordering::Less | Ordering::Equal)
}) {
Some(Sort::CaseSensitiveNaive)
} else {
None
}
@ -1069,9 +1096,16 @@ pub fn add_dependency(
Sort::CaseInsensitive => deps.iter().position(|d| {
case_insensitive(d, &Value::from(req_string.as_str())) == Ordering::Greater
}),
Sort::CaseInsensitiveNaive => deps.iter().position(|d| {
case_insensitive_naive(d, &Value::from(req_string.as_str()))
== Ordering::Greater
}),
Sort::CaseSensitive => deps.iter().position(|d| {
case_sensitive(d, &Value::from(req_string.as_str())) == Ordering::Greater
}),
Sort::CaseSensitiveNaive => deps.iter().position(|d| {
case_sensitive_naive(d, &Value::from(req_string.as_str())) == Ordering::Greater
}),
Sort::Unsorted => None,
};
let index = index.unwrap_or(deps.len());

View file

@ -7240,6 +7240,63 @@ fn sorted_dependencies() -> Result<()> {
Ok(())
}
/// Ensure that if the dependencies are sorted naively (i.e. by the whole
/// requirement specifier), that added dependencies are sorted in the same way.
#[test]
fn naive_sorted_dependencies() -> Result<()> {
let context = TestContext::new("3.12").with_filtered_counts();
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 = [
"pytest-mock>=3.14",
"pytest>=8.1.1",
]
"#})?;
uv_snapshot!(context.filters(), context.add().args(["pytest-randomly"]), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved [N] packages in [TIME]
Prepared [N] packages in [TIME]
Installed [N] packages in [TIME]
+ iniconfig==2.0.0
+ packaging==24.0
+ pluggy==1.4.0
+ pytest==8.1.1
+ pytest-mock==3.14.0
+ pytest-randomly==3.15.0
");
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 = [
"pytest-mock>=3.14",
"pytest-randomly>=3.15.0",
"pytest>=8.1.1",
]
"###
);
});
Ok(())
}
/// Ensure that the added dependencies are case sensitive sorted if the dependency list was already
/// case sensitive sorted prior to the operation.
#[test]
@ -7307,6 +7364,66 @@ fn case_sensitive_sorted_dependencies() -> Result<()> {
Ok(())
}
/// Ensure that if the dependencies are sorted naively (i.e. by the whole
/// requirement specifier), that added dependencies are sorted in the same way.
#[test]
fn case_sensitive_naive_sorted_dependencies() -> Result<()> {
let context = TestContext::new("3.12").with_filtered_counts();
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 = [
"Typing-extensions>=4.10.0",
"pytest-mock>=3.14",
"pytest>=8.1.1",
]
"#})?;
uv_snapshot!(context.filters(), context.add().args(["pytest-randomly"]), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved [N] packages in [TIME]
Prepared [N] packages in [TIME]
Installed [N] packages in [TIME]
+ iniconfig==2.0.0
+ packaging==24.0
+ pluggy==1.4.0
+ pytest==8.1.1
+ pytest-mock==3.14.0
+ pytest-randomly==3.15.0
+ typing-extensions==4.10.0
");
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 = [
"Typing-extensions>=4.10.0",
"pytest-mock>=3.14",
"pytest-randomly>=3.15.0",
"pytest>=8.1.1",
]
"###
);
});
Ok(())
}
/// Ensure that sorting is based on the name, rather than the combined name-and-specifiers.
#[test]
fn sorted_dependencies_name_specifiers() -> Result<()> {