Ignore global Python pins when incompatible with requires-python (#15473)

<!--
Thank you for contributing to uv! To help us out with reviewing, please
consider the following:

- Does this pull request include a summary of the change? (See below.)
- Does this pull request include a descriptive title?
- Does this pull request include references to any relevant issues?
-->

## 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 <contact@zanie.dev>
This commit is contained in:
Harsh Pratap Singh
2026-02-04 03:30:50 +05:30
committed by GitHub
parent 876023ac3d
commit cd49736234
4 changed files with 227 additions and 29 deletions
+166 -1
View File
@@ -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<Version> {
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<Version> {
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
);
}
}