Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions crates/uv-resolver/src/resolver/environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,11 +148,12 @@ impl ResolverEnvironment {
/// conflicting dependency specifications across distinct marker
/// environments.
///
/// The order of the initial forks is significant, although we don't
/// guarantee any specific treatment (similar to, at time of writing, how
/// the order of dependencies specified is also significant but has no
/// specific guarantees around it). Changing the ordering can help when our
/// custom fork prioritization fails.
/// Initial forks with distinct lower Python bounds are ordered by the fork
/// strategy and resolution mode, the same way forks created during
/// resolution are. The given order still decides between forks that tie,
/// although we don't guarantee any specific treatment (similar to, at time
/// of writing, how the order of dependencies specified is also significant
/// but has no specific guarantees around it).
pub fn universal(initial_forks: Vec<MarkerTree>) -> Self {
let kind = Kind::Universal {
initial_forks: initial_forks.into(),
Expand Down
48 changes: 35 additions & 13 deletions crates/uv-resolver/src/resolver/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,20 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
);
let mut preferences = self.preferences.clone();
let mut forked_states = self.env.initial_forked_states(state)?;

// Apply the same Python-bound scheduling used for dependency-created forks. Since states
// are popped from the end of the stack, sort lower Python bounds last for `fewest` and
// higher Python bounds last for `requires-python`. There's no `cmp_upper_bounds` tiebreak
// here: it counts upper-bounded specifiers among a fork's dependencies, which an initial
// state doesn't have yet.
match (self.options.fork_strategy, self.options.resolution_mode) {
(ForkStrategy::Fewest, _) | (_, ResolutionMode::Lowest) => {
forked_states.sort_by(|a, b| cmp_requires_python(&a.env, &b.env).reverse());
}
(ForkStrategy::RequiresPython, _) => {
forked_states.sort_by(|a, b| cmp_requires_python(&a.env, &b.env));
}
}
let mut resolutions = vec![];

'FORK: while let Some(mut state) = forked_states.pop() {
Expand Down Expand Up @@ -4248,20 +4262,9 @@ impl Fork {
Some(self)
}

/// Compare forks, preferring forks with g `requires-python` requirements.
/// Compare forks by their lower `requires-python` bounds.
fn cmp_requires_python(&self, other: &Self) -> Ordering {
// A higher `requires-python` requirement indicates a _higher-priority_ fork.
//
// This ordering ensures that we prefer choosing the highest version for each fork based on
// its `requires-python` requirement.
//
// The reverse would prefer choosing fewer versions, at the cost of using older package
// versions on newer Python versions. For example, if reversed, we'd prefer to solve `<3.7
// before solving `>=3.7`, since the resolution produced by the former might work for the
// latter, but the inverse is unlikely to be true.
let self_bound = self.env.requires_python().unwrap_or_default();
let other_bound = other.env.requires_python().unwrap_or_default();
self_bound.lower().cmp(other_bound.lower())
cmp_requires_python(&self.env, &other.env)
}

/// Compare forks, preferring forks with upper bounds.
Expand Down Expand Up @@ -4293,6 +4296,25 @@ impl Fork {
}
}

/// Compare resolver environments by their lower Python bounds.
fn cmp_requires_python(
self_env: &ResolverEnvironment,
other_env: &ResolverEnvironment,
) -> Ordering {
// A higher `requires-python` requirement indicates a _higher-priority_ fork.
//
// This ordering ensures that we prefer choosing the highest version for each fork based on
// its `requires-python` requirement.
//
// The reverse would prefer choosing fewer versions, at the cost of using older package
// versions on newer Python versions. For example, if reversed, we'd prefer to solve `<3.7
// before solving `>=3.7`, since the resolution produced by the former might work for the
// latter, but the inverse is unlikely to be true.
let self_bound = self_env.requires_python().unwrap_or_default();
let other_bound = other_env.requires_python().unwrap_or_default();
self_bound.lower().cmp(other_bound.lower())
}

impl Eq for Fork {}

impl PartialEq for Fork {
Expand Down
253 changes: 252 additions & 1 deletion crates/uv/tests/lock/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24076,8 +24076,8 @@ fn lock_split_python_environment() -> Result<()> {
revision = 3
requires-python = ">=3.7"
resolution-markers = [
"python_full_version < '3.8'",
"python_full_version >= '3.8'",
"python_full_version < '3.8'",
]
supported-markers = [
"python_full_version < '3.8'",
Expand Down Expand Up @@ -24143,6 +24143,257 @@ fn lock_split_python_environment() -> Result<()> {
Ok(())
}

/// Initial environment forks with distinct lower Python bounds should follow the effective fork
/// scheduling policy, including the precedence of lowest resolution over the fork strategy.
#[cfg(feature = "test-universal")]
#[test]
fn lock_fork_strategy_with_python_environments() -> Result<()> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just fyi you could also use a packse scenario for this

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(they're in-repo now)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

let context = uv_test::test_context!("3.12");
let server = PackseServer::new("fork/fork-strategy-environments.toml");

// The `requires-python` strategy solves the `3.12` fork first, so each fork gets its own
// highest version.
let requires_python = context.temp_dir.child("requires-python");
requires_python.create_dir_all()?;
requires_python
.child("pyproject.toml")
.write_str(indoc! {r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.11,<3.13"
dependencies = [
"a<=1.0.0 ; python_version < '3.12'",
"a<=2.0.0 ; python_version >= '3.12'",
]

[tool.uv]
fork-strategy = "requires-python"
environments = [
"python_version == '3.11'",
"python_version == '3.12'",
]
"#})?;

uv_snapshot!(context.filters(), context.lock().current_dir(&requires_python).arg("--index-url").arg(server.index_url()), @"
exit_code: 0 (success)
----- stderr -----
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
Resolved 3 packages in [TIME]
");

insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(context.read("requires-python/uv.lock"), @r#"
version = 1
revision = 3
requires-python = ">=3.11, <3.13"
resolution-markers = [
"python_full_version >= '3.12'",
"python_full_version < '3.12'",
]
supported-markers = [
"python_full_version < '3.12'",
"python_full_version >= '3.12'",
]

[options]
exclude-newer = "2024-03-25T00:00:00Z"

[[package]]
name = "a"
version = "1.0.0"
source = { registry = "http://[LOCALHOST]/simple/" }
resolution-markers = [
"python_full_version < '3.12'",
]
sdist = { url = "http://[LOCALHOST]/files/a-1.0.0.tar.gz", hash = "sha256:4816d803f3b4985959b41da3ae6da7ae3951b56465a53602dedc92c0c12ca685", upload-time = "2024-03-24T00:00:00Z" }
wheels = [
{ url = "http://[LOCALHOST]/files/a-1.0.0-py3-none-any.whl", hash = "sha256:3569209a9ecaea7636fa3b0ed97d6a9d50fccad1399a7dcf45caad2cbe59ae50", upload-time = "2024-03-24T00:00:00Z" },
]

[[package]]
name = "a"
version = "2.0.0"
source = { registry = "http://[LOCALHOST]/simple/" }
resolution-markers = [
"python_full_version >= '3.12'",
]
sdist = { url = "http://[LOCALHOST]/files/a-2.0.0.tar.gz", hash = "sha256:0818a47dd4fc0083f2e9dfc1486f9663e79b3c1ec8308803472c32d46a9dfa1c", upload-time = "2024-03-24T00:00:00Z" }
wheels = [
{ url = "http://[LOCALHOST]/files/a-2.0.0-py3-none-any.whl", hash = "sha256:498db1c24445774dde1dddcb558593590642d426a15b384c9f870fa89ddd24b4", upload-time = "2024-03-24T00:00:00Z" },
]

[[package]]
name = "project"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "a", version = "1.0.0", source = { registry = "http://[LOCALHOST]/simple/" }, marker = "python_full_version < '3.12'" },
{ name = "a", version = "2.0.0", source = { registry = "http://[LOCALHOST]/simple/" }, marker = "python_full_version >= '3.12'" },
]

[package.metadata]
requires-dist = [
{ name = "a", marker = "python_full_version < '3.12'", specifier = "<=1.0.0" },
{ name = "a", marker = "python_full_version >= '3.12'", specifier = "<=2.0.0" },
]
"#);
});

// The `fewest` strategy solves the `3.11` fork first, and its version carries over to the
// `3.12` fork.
let fewest = context.temp_dir.child("fewest");
fewest.create_dir_all()?;
fewest.child("pyproject.toml").write_str(indoc! {r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.11,<3.13"
dependencies = [
"a<=1.0.0 ; python_version < '3.12'",
"a<=2.0.0 ; python_version >= '3.12'",
]

[tool.uv]
fork-strategy = "fewest"
environments = [
"python_version == '3.12'",
"python_version == '3.11'",
]
"#})?;

uv_snapshot!(context.filters(), context.lock().current_dir(&fewest).arg("--index-url").arg(server.index_url()), @"
exit_code: 0 (success)
----- stderr -----
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
Resolved 2 packages in [TIME]
");

insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(context.read("fewest/uv.lock"), @r#"
version = 1
revision = 3
requires-python = ">=3.11, <3.13"
resolution-markers = [
"python_full_version < '3.12'",
"python_full_version >= '3.12'",
]
supported-markers = [
"python_full_version >= '3.12'",
"python_full_version < '3.12'",
]

[options]
fork-strategy = "fewest"
exclude-newer = "2024-03-25T00:00:00Z"

[[package]]
name = "a"
version = "1.0.0"
source = { registry = "http://[LOCALHOST]/simple/" }
sdist = { url = "http://[LOCALHOST]/files/a-1.0.0.tar.gz", hash = "sha256:4816d803f3b4985959b41da3ae6da7ae3951b56465a53602dedc92c0c12ca685", upload-time = "2024-03-24T00:00:00Z" }
wheels = [
{ url = "http://[LOCALHOST]/files/a-1.0.0-py3-none-any.whl", hash = "sha256:3569209a9ecaea7636fa3b0ed97d6a9d50fccad1399a7dcf45caad2cbe59ae50", upload-time = "2024-03-24T00:00:00Z" },
]

[[package]]
name = "project"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "a" },
]

[package.metadata]
requires-dist = [
{ name = "a", marker = "python_full_version < '3.12'", specifier = "<=1.0.0" },
{ name = "a", marker = "python_full_version >= '3.12'", specifier = "<=2.0.0" },
]
"#);
});

// The lowest resolution mode takes precedence over the `requires-python` strategy, so the
// `3.11` fork is solved first here too.
let lowest = context.temp_dir.child("lowest");
lowest.create_dir_all()?;
lowest.child("pyproject.toml").write_str(indoc! {r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.11,<3.13"
dependencies = [
"a==2.0.0 ; python_version < '3.12'",
"a>=1.0.0,<=2.0.0 ; python_version >= '3.12'",
]

[tool.uv]
resolution = "lowest"
fork-strategy = "requires-python"
environments = [
"python_version == '3.12'",
"python_version == '3.11'",
]
"#})?;

uv_snapshot!(context.filters(), context.lock().current_dir(&lowest).arg("--index-url").arg(server.index_url()), @"
exit_code: 0 (success)
----- stderr -----
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
Resolved 2 packages in [TIME]
");

insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(context.read("lowest/uv.lock"), @r#"
version = 1
revision = 3
requires-python = ">=3.11, <3.13"
resolution-markers = [
"python_full_version < '3.12'",
"python_full_version >= '3.12'",
]
supported-markers = [
"python_full_version >= '3.12'",
"python_full_version < '3.12'",
]

[options]
resolution-mode = "lowest"
exclude-newer = "2024-03-25T00:00:00Z"

[[package]]
name = "a"
version = "2.0.0"
source = { registry = "http://[LOCALHOST]/simple/" }
sdist = { url = "http://[LOCALHOST]/files/a-2.0.0.tar.gz", hash = "sha256:0818a47dd4fc0083f2e9dfc1486f9663e79b3c1ec8308803472c32d46a9dfa1c", upload-time = "2024-03-24T00:00:00Z" }
wheels = [
{ url = "http://[LOCALHOST]/files/a-2.0.0-py3-none-any.whl", hash = "sha256:498db1c24445774dde1dddcb558593590642d426a15b384c9f870fa89ddd24b4", upload-time = "2024-03-24T00:00:00Z" },
]

[[package]]
name = "project"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "a" },
]

[package.metadata]
requires-dist = [
{ name = "a", marker = "python_full_version < '3.12'", specifier = "==2.0.0" },
{ name = "a", marker = "python_full_version >= '3.12'", specifier = ">=1.0.0,<=2.0.0" },
]
"#);
});

Ok(())
}

/// Correctly narrow the Python requirement when upper bounds are present.
///
/// See: <https://github.com/astral-sh/uv/issues/6911>
Expand Down
26 changes: 26 additions & 0 deletions test/scenarios/fork/fork-strategy-environments.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name = "fork-strategy-environments"
description = '''
This test checks which version is selected when `environments` forks are solved
in a different order. Both versions of `a` work on every supported Python
version, so the fork that is solved first decides whether the other fork reuses
its version or picks its own.
'''

[testgen]
disable = true

[resolver_options]
universal = true

[expected]
satisfiable = true

[root]
requires_python = ">=3.11,<3.13"
requires = ["a"]

[packages.a.versions."1.0.0"]
requires_python = ">=3.11"

[packages.a.versions."2.0.0"]
requires_python = ">=3.11"
Loading