Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 166 additions & 1 deletion crates/uv-python/src/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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},
Expand Down Expand Up @@ -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
);
}
}
15 changes: 13 additions & 2 deletions crates/uv/src/commands/project/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
28 changes: 2 additions & 26 deletions crates/uv/src/commands/python/pin.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
use std::fmt::Write;
use std::path::Path;
use std::str::FromStr;

use anyhow::{Result, bail};
use owo_colors::OwoColorize;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -244,29 +243,6 @@ pub(crate) async fn pin(
Ok(ExitStatus::Success)
}

fn pep440_version_from_request(request: &PythonRequest) -> Option<uv_pep440::Version> {
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,
Expand All @@ -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,
Expand Down
46 changes: 46 additions & 0 deletions crates/uv/tests/it/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading