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