diff --git a/crates/uv-python/src/discovery.rs b/crates/uv-python/src/discovery.rs index 94b52bbdb6eef..8156c216b8d6c 100644 --- a/crates/uv-python/src/discovery.rs +++ b/crates/uv-python/src/discovery.rs @@ -2222,6 +2222,19 @@ impl PythonRequest { Self::Key(request) => request.to_string(), } } + + /// Convert an interpreter request into a concrete PEP 440 `Version` when possible. + /// + /// Returns `None` if the request doesn't carry an exact version + pub fn as_pep440_version(&self) -> Option { + match self { + Self::Version(v) | Self::ImplementationVersion(_, v) => v.as_pep440_version(), + Self::Key(download_request) => download_request + .version() + .and_then(VersionRequest::as_pep440_version), + _ => None, + } + } } impl PythonSource { @@ -3057,6 +3070,28 @@ impl VersionRequest { | Self::Range(_, variant) => Some(*variant), } } + + /// Convert this request into a concrete PEP 440 `Version` when possible. + /// + /// Returns `None` for non-concrete requests + pub fn as_pep440_version(&self) -> Option { + match self { + Self::Default | Self::Any | Self::Range(_, _) => None, + Self::Major(major, _) => Some(Version::new([u64::from(*major)])), + Self::MajorMinor(major, minor, _) => { + Some(Version::new([u64::from(*major), u64::from(*minor)])) + } + Self::MajorMinorPatch(major, minor, patch, _) => Some(Version::new([ + u64::from(*major), + u64::from(*minor), + u64::from(*patch), + ])), + // Pre-releases of Python versions are always for the zero patch version + Self::MajorMinorPrerelease(major, minor, prerelease, _) => Some( + Version::new([u64::from(*major), u64::from(*minor), 0]).with_pre(Some(*prerelease)), + ), + } + } } impl FromStr for VersionRequest { @@ -3454,7 +3489,7 @@ mod tests { use assert_fs::{TempDir, prelude::*}; use target_lexicon::{Aarch64Architecture, Architecture}; use test_log::test; - use uv_pep440::{Prerelease, PrereleaseKind, VersionSpecifiers}; + use uv_pep440::{Prerelease, PrereleaseKind, Version, VersionSpecifiers}; use crate::{ discovery::{PythonRequest, VersionRequest}, @@ -4120,4 +4155,134 @@ mod tests { // @ is not allowed if the prefix is empty. assert!(PythonRequest::try_split_prefix_and_version("", "@3").is_err()); } + + #[test] + fn version_request_as_pep440_version() { + // Non-concrete requests return `None` + assert_eq!(VersionRequest::Default.as_pep440_version(), None); + assert_eq!(VersionRequest::Any.as_pep440_version(), None); + assert_eq!( + VersionRequest::from_str(">=3.10") + .unwrap() + .as_pep440_version(), + None + ); + + // `VersionRequest::Major` + assert_eq!( + VersionRequest::Major(3, PythonVariant::Default).as_pep440_version(), + Some(Version::from_str("3").unwrap()) + ); + + // `VersionRequest::MajorMinor` + assert_eq!( + VersionRequest::MajorMinor(3, 12, PythonVariant::Default).as_pep440_version(), + Some(Version::from_str("3.12").unwrap()) + ); + + // `VersionRequest::MajorMinorPatch` + assert_eq!( + VersionRequest::MajorMinorPatch(3, 12, 5, PythonVariant::Default).as_pep440_version(), + Some(Version::from_str("3.12.5").unwrap()) + ); + + // `VersionRequest::MajorMinorPrerelease` + assert_eq!( + VersionRequest::MajorMinorPrerelease( + 3, + 14, + Prerelease { + kind: PrereleaseKind::Alpha, + number: 1 + }, + PythonVariant::Default + ) + .as_pep440_version(), + Some(Version::from_str("3.14.0a1").unwrap()) + ); + assert_eq!( + VersionRequest::MajorMinorPrerelease( + 3, + 14, + Prerelease { + kind: PrereleaseKind::Beta, + number: 2 + }, + PythonVariant::Default + ) + .as_pep440_version(), + Some(Version::from_str("3.14.0b2").unwrap()) + ); + assert_eq!( + VersionRequest::MajorMinorPrerelease( + 3, + 13, + Prerelease { + kind: PrereleaseKind::Rc, + number: 3 + }, + PythonVariant::Default + ) + .as_pep440_version(), + Some(Version::from_str("3.13.0rc3").unwrap()) + ); + + // Variant is ignored + assert_eq!( + VersionRequest::Major(3, PythonVariant::Freethreaded).as_pep440_version(), + Some(Version::from_str("3").unwrap()) + ); + assert_eq!( + VersionRequest::MajorMinor(3, 13, PythonVariant::Freethreaded).as_pep440_version(), + Some(Version::from_str("3.13").unwrap()) + ); + } + + #[test] + fn python_request_as_pep440_version() { + // `PythonRequest::Any` and `PythonRequest::Default` return `None` + assert_eq!(PythonRequest::Any.as_pep440_version(), None); + assert_eq!(PythonRequest::Default.as_pep440_version(), None); + + // `PythonRequest::Version` delegates to `VersionRequest` + assert_eq!( + PythonRequest::Version(VersionRequest::MajorMinor(3, 11, PythonVariant::Default)) + .as_pep440_version(), + Some(Version::from_str("3.11").unwrap()) + ); + + // `PythonRequest::ImplementationVersion` extracts version + assert_eq!( + PythonRequest::ImplementationVersion( + ImplementationName::CPython, + VersionRequest::MajorMinorPatch(3, 12, 1, PythonVariant::Default), + ) + .as_pep440_version(), + Some(Version::from_str("3.12.1").unwrap()) + ); + + // `PythonRequest::Implementation` returns `None` (no version) + assert_eq!( + PythonRequest::Implementation(ImplementationName::CPython).as_pep440_version(), + None + ); + + // `PythonRequest::Key` with version + assert_eq!( + PythonRequest::parse("cpython-3.13.2").as_pep440_version(), + Some(Version::from_str("3.13.2").unwrap()) + ); + + // `PythonRequest::Key` without version returns `None` + assert_eq!( + PythonRequest::parse("cpython-macos-aarch64-none").as_pep440_version(), + None + ); + + // Range versions return `None` + assert_eq!( + PythonRequest::Version(VersionRequest::from_str(">=3.10").unwrap()).as_pep440_version(), + None + ); + } } diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index fc706b2436c32..4b81325809a04 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -1196,10 +1196,21 @@ impl WorkspacePython { .with_no_config(no_config), ) .await? - { + .filter(|file| { + // Ignore global version files that are incompatible with requires-python + if !file.is_global() { + return true; + } + match (file.version(), requires_python.as_ref()) { + (Some(request), Some(requires_python)) => request + .as_pep440_version() + .is_none_or(|version| requires_python.contains(&version)), + _ => true, + } + }) { // (2) Request from `.python-version` let source = PythonRequestSource::DotPythonVersion(file.clone()); - let request = file.into_version(); + let request = file.version().cloned(); (source, request) } else { // (3) `requires-python` in `pyproject.toml` diff --git a/crates/uv/src/commands/python/pin.rs b/crates/uv/src/commands/python/pin.rs index b566ec8e7e951..31bdf1d81a13c 100644 --- a/crates/uv/src/commands/python/pin.rs +++ b/crates/uv/src/commands/python/pin.rs @@ -1,6 +1,5 @@ use std::fmt::Write; use std::path::Path; -use std::str::FromStr; use anyhow::{Result, bail}; use owo_colors::OwoColorize; @@ -157,7 +156,7 @@ pub(crate) async fn pin( }; if let Some(virtual_project) = &virtual_project { - if let Some(request_version) = pep440_version_from_request(&request) { + if let Some(request_version) = request.as_pep440_version() { assert_pin_compatible_with_project( &Pin { request: &request, @@ -244,29 +243,6 @@ pub(crate) async fn pin( Ok(ExitStatus::Success) } -fn pep440_version_from_request(request: &PythonRequest) -> Option { - let version_request = match request { - PythonRequest::Version(version) | PythonRequest::ImplementationVersion(_, version) => { - version - } - PythonRequest::Key(download_request) => download_request.version()?, - _ => { - return None; - } - }; - - if matches!(version_request, uv_python::VersionRequest::Range(_, _)) { - return None; - } - - // SAFETY: converting `VersionRequest` to `Version` is guaranteed to succeed if not a `Range` - // and does not have a Python variant (e.g., freethreaded) attached. - Some( - uv_pep440::Version::from_str(&version_request.clone().without_python_variant().to_string()) - .unwrap(), - ) -} - /// Check if pinned request is compatible with the workspace/project's `Requires-Python`. fn warn_if_existing_pin_incompatible_with_project( pin: &PythonRequest, @@ -277,7 +253,7 @@ fn warn_if_existing_pin_incompatible_with_project( preview: Preview, ) { // Check if the pinned version is compatible with the project. - if let Some(pin_version) = pep440_version_from_request(pin) { + if let Some(pin_version) = pin.as_pep440_version() { if let Err(err) = assert_pin_compatible_with_project( &Pin { request: pin, diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 34cac43206caf..e946ce43c2fd1 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -9017,6 +9017,52 @@ fn sync_python_version() -> Result<()> { Ok(()) } +/// Test that a global `.python-version` pin that conflicts with the project's +/// `requires-python` is ignored, falling back to the project's requirement. +#[test] +fn sync_ignores_incompatible_global_python_version() -> Result<()> { + let context = TestContext::new_with_versions(&["3.10", "3.11"]); + + // Create a global pin before creating the project (to avoid pin compatibility check) + uv_snapshot!(context.filters(), context.python_pin().arg("--global").arg("3.10"), @r" + success: true + exit_code: 0 + ----- stdout ----- + Pinned `[UV_USER_CONFIG_DIR]/.python-version` to `3.10` + + ----- stderr ----- + "); + + // Now create a project that requires a different Python version + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc::indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.11" + dependencies = ["anyio==3.7.0"] + "#})?; + + // Ensure sync succeeds and uses a compatible interpreter (ignoring the conflicting global pin) + uv_snapshot!(context.filters(), context.sync(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.11.[X] interpreter at: [PYTHON-3.11] + Creating virtual environment at: .venv + Resolved 4 packages in [TIME] + Prepared 3 packages in [TIME] + Installed 3 packages in [TIME] + + anyio==3.7.0 + + idna==3.6 + + sniffio==1.3.1 + "); + + Ok(()) +} + #[test] fn sync_explicit() -> Result<()> { let context = TestContext::new("3.12");