From cd4973623485aeda775d2721dddd95c269fa131b Mon Sep 17 00:00:00 2001 From: Harsh Pratap Singh Date: Wed, 4 Feb 2026 03:30:50 +0530 Subject: [PATCH] Ignore global Python pins when incompatible with `requires-python` (#15473) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary @zanieb in #14916 found an interesting bug. Global pin is the user preference across projects, but if the project locally has a local pin, it should be an authoritative constraint and end up with error. This avoids blocking new projects that intentionally require a newer Python version than the user’s global default. We still error on a local `.python-version` inside the project to preserve explicit, repo-scoped intent. * Explicit `--python` → Always wins regardless of constraints * Local `.python-version` → Project-scoped, errors on conflict * Global `.python-version` → User preference, ignored if conflicts with project. Global pins are suggestions that can be overridden by project requirements * Project `requires-python` → Fallback when no pins exist Implementation : * If a global `~/.config/uv/.python-version` conflicts with a project `requires-python`, we ignore the pin and use the project requirement * If a local project `.python-version` conflicts, we error, with guidance to update the pin * Explicit `--python` continues to override both Now, global pins are suggestions that can be overridden by project requirements, rather than hard constraints that block project setup. ## Test Plan A new has been added in `sync_python_version()` along with manual testing : ``` harshps22ugp@lab:~/projects/uv$ target/debug/uv python pin --global 3.10 Pinned `/home/harshps22ugp/.config/uv/.python-version` to `3.10` harshps22ugp@lab:~/projects/uv$ mkdir -p /tmp/uv-global-pin && cd /tmp/uv-global-pin harshps22ugp@lab:/tmp/uv-global-pin$ cat > pyproject.toml <<'EOF' > [project] > name = "project" > version = "0.1.0" > requires-python = ">=3.11" > dependencies = ["anyio==3.7.0"] > EOF harshps22ugp@lab:/tmp/uv-global-pin$ /home/harshps22ugp/projects/uv/target/debug/uv sync Using CPython 3.13.5 Creating virtual environment at: .venv Resolved 4 packages in 276ms Prepared 3 packages in 149ms ░░░░░░░░░░░░░░░░░░░░ [0/3] Installing wheels... warning: Failed to hardlink files; falling back to full copy. This may lead to degraded performance. If the cache and target directories are on different filesystems, hardlinking may not be supported. If this is intentional, set `export UV_LINK_MODE=copy` or use `--link-mode=copy` to suppress this warning. Installed 3 packages in 35ms + anyio==3.7.0 + idna==3.10 + sniffio==1.3.1 harshps22ugp@lab:/tmp/uv-global-pin$ . .venv/bin/activate (project) harshps22ugp@lab:/tmp/uv-global-pin$ python -V Python 3.13.5 (project) harshps22ugp@lab:/tmp/uv-global-pin$ ``` No error was thrown! --------- Co-authored-by: Zanie Blue --- crates/uv-python/src/discovery.rs | 167 +++++++++++++++++++++++++- crates/uv/src/commands/project/mod.rs | 15 ++- crates/uv/src/commands/python/pin.rs | 28 +---- crates/uv/tests/it/sync.rs | 46 +++++++ 4 files changed, 227 insertions(+), 29 deletions(-) diff --git a/crates/uv-python/src/discovery.rs b/crates/uv-python/src/discovery.rs index 94b52bbdb..8156c216b 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 fc706b243..4b8132580 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 b566ec8e7..31bdf1d81 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 34cac4320..e946ce43c 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");