From bf8934e3e442efb4be5af3d0d4db60ce6d72c600 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 31 Jul 2024 12:08:53 -0400 Subject: [PATCH] Use intersection rather than union for `requires-python` (#5644) ## Summary As-is, if you have a workspace with mixed `requires-python` requirements, resolution will _never_ succeed, since we'll use the union as the `requires-python` bound (i.e., take the lowest value), and fail when we see the package that only supports some more narrow range. This PR modifies the behavior to take the intersection (i.e., the highest value), so if you have one package that supports Python 3.12 and later, and another that supports Python 3.8 and later, we lock for Python 3.12. If you try to sync or run with Python 3.8, we raise an error, since the lockfile will be incompatible with that request. Konsti has a write-up in https://github.com/astral-sh/uv/issues/5594 that outlines what could be a longer-term strategy. Closes https://github.com/astral-sh/uv/issues/5578. --- crates/uv-resolver/src/requires_python.rs | 39 +++++----- crates/uv/src/commands/project/mod.rs | 2 +- crates/uv/tests/sync.rs | 89 +++++++++++++++++++++++ 3 files changed, 107 insertions(+), 23 deletions(-) diff --git a/crates/uv-resolver/src/requires_python.rs b/crates/uv-resolver/src/requires_python.rs index 9529173400fe..a828210bd413 100644 --- a/crates/uv-resolver/src/requires_python.rs +++ b/crates/uv-resolver/src/requires_python.rs @@ -64,19 +64,19 @@ impl RequiresPython { }) } - /// Returns a [`RequiresPython`] to express the union of the given version specifiers. + /// Returns a [`RequiresPython`] to express the intersection of the given version specifiers. /// - /// For example, given `>=3.8` and `>=3.9`, this would return `>=3.8`. - pub fn union<'a>( + /// For example, given `>=3.8` and `>=3.9`, this would return `>=3.9`. + pub fn intersection<'a>( specifiers: impl Iterator, ) -> Result, RequiresPythonError> { - // Convert to PubGrub range and perform a union. + // Convert to PubGrub range and perform an intersection. let range = specifiers .into_iter() .map(crate::pubgrub::PubGrubSpecifier::from_release_specifiers) .fold_ok(None, |range: Option>, requires_python| { if let Some(range) = range { - Some(range.union(&requires_python.into())) + Some(range.intersection(&requires_python.into())) } else { Some(requires_python.into()) } @@ -107,11 +107,14 @@ impl RequiresPython { /// Narrow the [`RequiresPython`] to the given version, if it's stricter (i.e., greater) than /// the current target. pub fn narrow(&self, target: &RequiresPythonBound) -> Option { - let target = VersionSpecifiers::from(VersionSpecifier::from_lower_bound(target)?); - Self::union(std::iter::once(&target)) - .ok() - .flatten() - .filter(|next| next.bound > self.bound) + if target > &self.bound { + Some(Self { + specifiers: VersionSpecifiers::from(VersionSpecifier::from_lower_bound(target)?), + bound: target.clone(), + }) + } else { + None + } } /// Returns `true` if the `Requires-Python` is compatible with the given version. @@ -418,9 +421,7 @@ mod tests { #[test] fn requires_python_included() { let version_specifiers = VersionSpecifiers::from_str("==3.10.*").unwrap(); - let requires_python = RequiresPython::union(std::iter::once(&version_specifiers)) - .unwrap() - .unwrap(); + let requires_python = RequiresPython::from_specifiers(&version_specifiers).unwrap(); let wheel_names = &[ "bcrypt-4.1.3-cp37-abi3-macosx_10_12_universal2.whl", "black-24.4.2-cp310-cp310-win_amd64.whl", @@ -437,9 +438,7 @@ mod tests { } let version_specifiers = VersionSpecifiers::from_str(">=3.12.3").unwrap(); - let requires_python = RequiresPython::union(std::iter::once(&version_specifiers)) - .unwrap() - .unwrap(); + let requires_python = RequiresPython::from_specifiers(&version_specifiers).unwrap(); let wheel_names = &["dearpygui-1.11.1-cp312-cp312-win_amd64.whl"]; for wheel_name in wheel_names { assert!( @@ -452,9 +451,7 @@ mod tests { #[test] fn requires_python_dropped() { let version_specifiers = VersionSpecifiers::from_str("==3.10.*").unwrap(); - let requires_python = RequiresPython::union(std::iter::once(&version_specifiers)) - .unwrap() - .unwrap(); + let requires_python = RequiresPython::from_specifiers(&version_specifiers).unwrap(); let wheel_names = &[ "PySocks-1.7.1-py27-none-any.whl", "black-24.4.2-cp39-cp39-win_amd64.whl", @@ -471,9 +468,7 @@ mod tests { } let version_specifiers = VersionSpecifiers::from_str(">=3.12.3").unwrap(); - let requires_python = RequiresPython::union(std::iter::once(&version_specifiers)) - .unwrap() - .unwrap(); + let requires_python = RequiresPython::from_specifiers(&version_specifiers).unwrap(); let wheel_names = &["dearpygui-1.11.1-cp310-cp310-win_amd64.whl"]; for wheel_name in wheel_names { assert!( diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 999c89db9f8f..63df358af9c6 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -101,7 +101,7 @@ pub(crate) enum ProjectError { pub(crate) fn find_requires_python( workspace: &Workspace, ) -> Result, uv_resolver::RequiresPythonError> { - RequiresPython::union(workspace.packages().values().filter_map(|member| { + RequiresPython::intersection(workspace.packages().values().filter_map(|member| { member .pyproject_toml() .project diff --git a/crates/uv/tests/sync.rs b/crates/uv/tests/sync.rs index 55efff527f25..bc2b0c5c0d21 100644 --- a/crates/uv/tests/sync.rs +++ b/crates/uv/tests/sync.rs @@ -271,3 +271,92 @@ fn package() -> Result<()> { Ok(()) } + +/// Ensure that we use the maximum Python version when a workspace contains mixed requirements. +#[test] +fn mixed_requires_python() -> Result<()> { + let context = TestContext::new_with_versions(&["3.8", "3.12"]); + + // Create a workspace root with a minimum Python requirement of Python 3.12. + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "albatross" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["bird-feeder", "anyio>3"] + + [tool.uv.sources] + bird-feeder = { workspace = true } + + [tool.uv.workspace] + members = ["packages/*"] + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + "#, + )?; + + let src = context.temp_dir.child("src").child("albatross"); + src.create_dir_all()?; + + let init = src.child("__init__.py"); + init.touch()?; + + // Create a child with a minimum Python requirement of Python 3.8. + let child = context.temp_dir.child("packages").child("bird-feeder"); + child.create_dir_all()?; + + let src = context.temp_dir.child("src").child("bird_feeder"); + src.create_dir_all()?; + + let init = src.child("__init__.py"); + init.touch()?; + + let pyproject_toml = child.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "bird-feeder" + version = "0.1.0" + requires-python = ">=3.8" + "#, + )?; + + // Running `uv sync` should succeed, locking for Python 3.12. + uv_snapshot!(context.filters(), context.sync().arg("-p").arg("3.12"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv sync` is experimental and may change without warning + Using Python 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtualenv at: .venv + warning: `uv.sources` is experimental and may change without warning + Resolved 5 packages in [TIME] + Prepared 5 packages in [TIME] + Installed 5 packages in [TIME] + + albatross==0.1.0 (from file://[TEMP_DIR]/) + + anyio==4.3.0 + + bird-feeder==0.1.0 (from file://[TEMP_DIR]/packages/bird-feeder) + + idna==3.6 + + sniffio==1.3.1 + "###); + + // Running `uv sync` again should succeed. + uv_snapshot!(context.filters(), context.sync().arg("-p").arg("3.8"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: `uv sync` is experimental and may change without warning + Using Python 3.8.[X] interpreter at: [PYTHON-3.8] + error: The requested Python interpreter (3.8.[X]) is incompatible with the project Python requirement: `>=3.12` + "###); + + Ok(()) +}