sort dependencies in pyproject.toml (#6388)

## Summary
resolves https://github.com/astral-sh/uv/issues/6203

## Test Plan
added a test fixing the bug described in the issue.

---------

Co-authored-by: konstin <konstin@mailbox.org>
This commit is contained in:
Chan Kang 2024-08-29 10:55:03 -04:00 committed by GitHub
parent f956ab8fae
commit 4f5356ed55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 171 additions and 18 deletions

1
Cargo.lock generated
View file

@ -5281,6 +5281,7 @@ dependencies = [
"fs-err",
"glob",
"insta",
"itertools 0.13.0",
"pep440_rs",
"pep508_rs",
"pypi-types",

View file

@ -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"] }

View file

@ -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<ArrayEdit, Error> {
// 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);

View file

@ -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(())
}