diff --git a/Cargo.lock b/Cargo.lock index 509cdc3ac8fb8..274a88a1b6237 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6806,6 +6806,7 @@ dependencies = [ "uv-client", "uv-dirs", "uv-distribution-filename", + "uv-distribution-types", "uv-extract", "uv-fs", "uv-install-wheel", diff --git a/crates/uv-python/Cargo.toml b/crates/uv-python/Cargo.toml index 2917c94dd6286..79b27b3896b72 100644 --- a/crates/uv-python/Cargo.toml +++ b/crates/uv-python/Cargo.toml @@ -22,6 +22,7 @@ uv-cache-key = { workspace = true } uv-client = { workspace = true } uv-dirs = { workspace = true } uv-distribution-filename = { workspace = true } +uv-distribution-types = { workspace = true } uv-extract = { workspace = true } uv-fs = { workspace = true } uv-install-wheel = { workspace = true } diff --git a/crates/uv-python/src/discovery.rs b/crates/uv-python/src/discovery.rs index c36317162ce7c..b4a285a43f6a1 100644 --- a/crates/uv-python/src/discovery.rs +++ b/crates/uv-python/src/discovery.rs @@ -13,6 +13,7 @@ use thiserror::Error; use tracing::{debug, instrument, trace}; use uv_cache::Cache; use uv_client::BaseClient; +use uv_distribution_types::RequiresPython; use uv_fs::Simplified; use uv_fs::which::is_executable; use uv_pep440::{ @@ -2241,9 +2242,54 @@ impl PythonRequest { Self::Key(download_request) => download_request .version() .and_then(VersionRequest::as_pep440_version), - _ => None, + Self::Default + | Self::Any + | Self::Directory(_) + | Self::File(_) + | Self::ExecutableName(_) + | Self::Implementation(_) => None, } } + + /// Convert an interpreter request into [`VersionSpecifiers`] representing the range of + /// compatible versions. + /// + /// Returns `None` if the request doesn't carry version constraints (e.g., a path or + /// executable name). + pub fn as_version_specifiers(&self) -> Option { + match self { + Self::Version(version) | Self::ImplementationVersion(_, version) => { + version.as_version_specifiers() + } + Self::Key(download_request) => download_request + .version() + .and_then(VersionRequest::as_version_specifiers), + Self::Default + | Self::Any + | Self::Directory(_) + | Self::File(_) + | Self::ExecutableName(_) + | Self::Implementation(_) => None, + } + } + + /// Returns `true` when this request is compatible with the given `requires-python` specifier. + /// + /// Requests without version constraints (e.g., paths, executable names) are always considered + /// compatible. For versioned requests, compatibility means the request's version range has a + /// non-empty intersection with the `requires-python` range. + pub fn intersects_requires_python(&self, requires_python: &RequiresPython) -> bool { + let Some(specifiers) = self.as_version_specifiers() else { + return true; + }; + + let request_range = release_specifiers_to_ranges(specifiers); + let requires_python_range = + release_specifiers_to_ranges(requires_python.specifiers().clone()); + !request_range + .intersection(&requires_python_range) + .is_empty() + } } impl PythonSource { @@ -3101,6 +3147,38 @@ impl VersionRequest { ), } } + + /// Convert this request into [`VersionSpecifiers`] representing the range of compatible + /// versions. + /// + /// Returns `None` for requests without version constraints (e.g., [`VersionRequest::Default`] + /// and [`VersionRequest::Any`]). + pub fn as_version_specifiers(&self) -> Option { + match self { + Self::Default | Self::Any => None, + Self::Major(major, _) => Some(VersionSpecifiers::from( + VersionSpecifier::equals_star_version(Version::new([u64::from(*major)])), + )), + Self::MajorMinor(major, minor, _) => Some(VersionSpecifiers::from( + VersionSpecifier::equals_star_version(Version::new([ + u64::from(*major), + u64::from(*minor), + ])), + )), + Self::MajorMinorPatch(major, minor, patch, _) => { + Some(VersionSpecifiers::from(VersionSpecifier::equals_version( + Version::new([u64::from(*major), u64::from(*minor), u64::from(*patch)]), + ))) + } + Self::MajorMinorPrerelease(major, minor, prerelease, _) => { + Some(VersionSpecifiers::from(VersionSpecifier::equals_version( + Version::new([u64::from(*major), u64::from(*minor), 0]) + .with_pre(Some(*prerelease)), + ))) + } + Self::Range(specifiers, _) => Some(specifiers.clone()), + } + } } impl FromStr for VersionRequest { @@ -3498,6 +3576,7 @@ mod tests { use assert_fs::{TempDir, prelude::*}; use target_lexicon::{Aarch64Architecture, Architecture}; use test_log::test; + use uv_distribution_types::RequiresPython; use uv_pep440::{Prerelease, PrereleaseKind, Version, VersionSpecifiers}; use crate::{ @@ -4294,4 +4373,62 @@ mod tests { None ); } + + #[test] + fn intersects_requires_python_exact() { + let requires_python = + RequiresPython::from_specifiers(&VersionSpecifiers::from_str(">=3.12").unwrap()); + + assert!(PythonRequest::parse("3.12").intersects_requires_python(&requires_python)); + assert!(!PythonRequest::parse("3.11").intersects_requires_python(&requires_python)); + } + + #[test] + fn intersects_requires_python_major() { + let requires_python = + RequiresPython::from_specifiers(&VersionSpecifiers::from_str(">=3.12").unwrap()); + + // `3` overlaps with `>=3.12` (e.g., 3.12, 3.13, ... are all Python 3) + assert!(PythonRequest::parse("3").intersects_requires_python(&requires_python)); + // `2` does not overlap with `>=3.12` + assert!(!PythonRequest::parse("2").intersects_requires_python(&requires_python)); + } + + #[test] + fn intersects_requires_python_range() { + let requires_python = + RequiresPython::from_specifiers(&VersionSpecifiers::from_str(">=3.12").unwrap()); + + assert!(PythonRequest::parse(">=3.12,<3.13").intersects_requires_python(&requires_python)); + assert!(!PythonRequest::parse(">=3.10,<3.12").intersects_requires_python(&requires_python)); + } + + #[test] + fn intersects_requires_python_implementation_range() { + let requires_python = + RequiresPython::from_specifiers(&VersionSpecifiers::from_str(">=3.12").unwrap()); + + assert!( + PythonRequest::parse("cpython@>=3.12,<3.13") + .intersects_requires_python(&requires_python) + ); + assert!( + !PythonRequest::parse("cpython@>=3.10,<3.12") + .intersects_requires_python(&requires_python) + ); + } + + #[test] + fn intersects_requires_python_no_version() { + let requires_python = + RequiresPython::from_specifiers(&VersionSpecifiers::from_str(">=3.12").unwrap()); + + // Requests without version constraints are always compatible + assert!(PythonRequest::Any.intersects_requires_python(&requires_python)); + assert!(PythonRequest::Default.intersects_requires_python(&requires_python)); + assert!( + PythonRequest::Implementation(ImplementationName::CPython) + .intersects_requires_python(&requires_python) + ); + } } diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 5f79fefe5eb06..ee38063c10bc8 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -1257,48 +1257,96 @@ pub(crate) struct ScriptPython { } impl ScriptPython { - /// Determine the [`ScriptPython`] for the current [`Workspace`]. + /// Determine the [`ScriptPython`] for the current [`Pep723Script`]. pub(crate) async fn from_request( python_request: Option, workspace: Option<&Workspace>, script: Pep723ItemRef<'_>, no_config: bool, ) -> Result { - // First, discover a requirement from the workspace - let WorkspacePython { - mut source, - mut python_request, - requires_python, - } = WorkspacePython::from_request( - python_request, - workspace, - // Scripts have no groups to hang requires-python settings off of - &DependencyGroupsWithDefaults::none(), - script.path().and_then(Path::parent).unwrap_or(&**CWD), - no_config, - ) - .await?; + let script_requires_python = script + .metadata() + .requires_python + .as_ref() + .map(RequiresPython::from_specifiers); - // If the script has a `requires-python` specifier, prefer that over one from the workspace. - let requires_python = - if let Some(requires_python_specifiers) = script.metadata().requires_python.as_ref() { - if python_request.is_none() { - python_request = Some(PythonRequest::Version(VersionRequest::Range( - requires_python_specifiers.clone(), - PythonVariant::Default, - ))); - source = PythonRequestSource::RequiresPython; + let workspace_requires_python = workspace + .map(|workspace| find_requires_python(workspace, &DependencyGroupsWithDefaults::none())) + .transpose()? + .flatten(); + + let workspace_root = workspace.map(Workspace::install_path); + let project_dir = script.path().and_then(Path::parent).unwrap_or(&**CWD); + + let (source, python_request) = if let Some(request) = python_request { + // (1) Explicit request from user + (PythonRequestSource::UserRequest, Some(request)) + } else if let Some(file) = PythonVersionFile::discover( + project_dir, + &VersionFileDiscoveryOptions::default() + .with_stop_discovery_at(workspace_root.map(PathBuf::as_ref)) + .with_no_config(no_config), + ) + .await? + .filter(|file| { + // Ignore version files that are incompatible with the script's `requires-python` + match (file.version(), script_requires_python.as_ref()) { + (Some(request), Some(requires_python)) => { + request.intersects_requires_python(requires_python) } - Some(( - RequiresPython::from_specifiers(requires_python_specifiers), - RequiresPythonSource::Script, - )) - } else { - requires_python.map(|requirement| (requirement, RequiresPythonSource::Project)) - }; + _ => true, + } + }) + .filter(|file| { + // Ignore global version files that are incompatible with the workspace `requires-python` + if !file.is_global() { + return true; + } + match (file.version(), workspace_requires_python.as_ref()) { + (Some(request), Some(requires_python)) => { + request.intersects_requires_python(requires_python) + } + _ => true, + } + }) { + // (2) Request from `.python-version` + ( + PythonRequestSource::DotPythonVersion(file.clone()), + file.version().cloned(), + ) + } else if let Some(specifiers) = script.metadata().requires_python.as_ref() { + // (3) `requires-python` from script metadata + let request = PythonRequest::Version(VersionRequest::Range( + specifiers.clone(), + PythonVariant::Default, + )); + (PythonRequestSource::RequiresPython, Some(request)) + } else { + // (4) `requires-python` from workspace `pyproject.toml` + let request = workspace_requires_python + .as_ref() + .map(RequiresPython::specifiers) + .map(|specifiers| { + PythonRequest::Version(VersionRequest::Range( + specifiers.clone(), + PythonVariant::Default, + )) + }); + (PythonRequestSource::RequiresPython, request) + }; + + let requires_python = if let Some(requires_python) = script_requires_python { + Some((requires_python, RequiresPythonSource::Script)) + } else { + workspace_requires_python + .map(|requires_python| (requires_python, RequiresPythonSource::Project)) + }; if let Some(python_request) = python_request.as_ref() { - debug!("Using Python request {python_request} from {source}"); + debug!( + "Using Python request `{}` from {source}", + python_request.to_canonical_string() + ); } Ok(Self { diff --git a/crates/uv/tests/it/run.rs b/crates/uv/tests/it/run.rs index 8e8526f881775..7d02b99038fb7 100644 --- a/crates/uv/tests/it/run.rs +++ b/crates/uv/tests/it/run.rs @@ -475,59 +475,111 @@ fn run_pep723_script() -> Result<()> { #[test] fn run_pep723_script_requires_python() -> Result<()> { - let context = uv_test::test_context_with_versions!(&["3.9", "3.11"]); + let context = uv_test::test_context_with_versions!(&["3.11", "3.12"]); - // If we have a `.python-version` that's incompatible with the script, we should error. + // If we have a `.python-version` that's incompatible with the script, we should use the + // script's `requires-python` for Python discovery instead. let python_version = context.temp_dir.child(PYTHON_VERSION_FILENAME); - python_version.write_str("3.9")?; + python_version.write_str("3.11")?; - // If the script contains a PEP 723 tag, we should install its requirements. let test_script = context.temp_dir.child("main.py"); test_script.write_str(indoc! { r#" # /// script - # requires-python = ">=3.11" - # dependencies = [ - # "iniconfig", - # ] + # requires-python = ">=3.12" # /// - import iniconfig - - x: str | int = "hello" - print(x) + import platform + print(platform.python_version()) "# })?; - uv_snapshot!(context.filters(), context.run().arg("main.py"), @r#" - success: false - exit_code: 1 + // The `.python-version` (3.11) is incompatible with the script's `requires-python` (>=3.12), + // so uv should ignore it and discover a compatible Python (3.12) instead. + uv_snapshot!(context.filters(), context.run().arg("main.py"), @r" + success: true + exit_code: 0 ----- stdout ----- + 3.12.[X] ----- stderr ----- - warning: The Python request from `.python-version` resolved to Python 3.9.[X], which is incompatible with the script's Python requirement: `>=3.11` - Resolved 1 package in [TIME] - Prepared 1 package in [TIME] - Installed 1 package in [TIME] - + iniconfig==2.0.0 - Traceback (most recent call last): - File "[TEMP_DIR]/main.py", line 10, in - x: str | int = "hello" - TypeError: unsupported operand type(s) for |: 'type' and 'type' - "#); + "); - // Delete the `.python-version` file to allow the script to run. + // Deleting the `.python-version` file should not change the behavior. fs_err::remove_file(&python_version)?; - uv_snapshot!(context.filters(), context.run().arg("main.py"), @" + uv_snapshot!(context.filters(), context.run().arg("main.py"), @r" success: true exit_code: 0 ----- stdout ----- - hello + 3.12.[X] + + ----- stderr ----- + "); + + Ok(()) +} + +/// When a `.python-version` is compatible with a script's `requires-python`, the `.python-version` +/// should be used. +#[test] +fn run_pep723_script_requires_python_compatible() -> Result<()> { + let context = uv_test::test_context_with_versions!(&["3.11", "3.12"]); + + let python_version = context.temp_dir.child(PYTHON_VERSION_FILENAME); + python_version.write_str("3.11")?; + + let test_script = context.temp_dir.child("main.py"); + test_script.write_str(indoc! { r#" + # /// script + # requires-python = ">=3.11" + # /// + + import platform + print(platform.python_version()) + "# + })?; + + // The `.python-version` (3.11) is compatible with the script's `requires-python` (>=3.11), + // so it should be used. + uv_snapshot!(context.filters(), context.run().arg("main.py"), @r" + success: true + exit_code: 0 + ----- stdout ----- + 3.11.[X] + + ----- stderr ----- + "); + + Ok(()) +} + +/// When `.python-version` specifies an incompatible range, script `requires-python` should be used +/// for discovery. +#[test] +fn run_pep723_script_requires_python_incompatible_range() -> Result<()> { + let context = uv_test::test_context_with_versions!(&["3.11", "3.12"]); + + let python_version = context.temp_dir.child(PYTHON_VERSION_FILENAME); + python_version.write_str(">3.8,<3.12")?; + + let test_script = context.temp_dir.child("main.py"); + test_script.write_str(indoc! { r#" + # /// script + # requires-python = ">=3.12" + # /// + + import platform + print(platform.python_version()) + "# + })?; + + uv_snapshot!(context.filters(), context.run().arg("main.py"), @r" + success: true + exit_code: 0 + ----- stdout ----- + 3.12.[X] ----- stderr ----- - Resolved 1 package in [TIME] - Installed 1 package in [TIME] - + iniconfig==2.0.0 "); Ok(())