Fix Python version selection for scripts with a requires-python conflicting with .python-version (#18097)

See https://github.com/astral-sh/uv/issues/17717#issuecomment-3886377795

Updates `ScriptPython::from_request` to match the implementation of
`WorkspacePython::from_request` and adds filtering such that if the
`.python-version` conflicts with the `requires-python`, we prefer the
latter.

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Zanie Blue
2026-02-24 05:16:48 -06:00
committed by GitHub
parent f5e00a36f0
commit f5b95ba548
5 changed files with 308 additions and 69 deletions
Generated
+1
View File
@@ -6883,6 +6883,7 @@ dependencies = [
"uv-client",
"uv-dirs",
"uv-distribution-filename",
"uv-distribution-types",
"uv-extract",
"uv-fs",
"uv-install-wheel",
+1
View File
@@ -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 }
+138 -1
View File
@@ -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<VersionSpecifiers> {
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 {
@@ -3109,6 +3155,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<VersionSpecifiers> {
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 {
@@ -3506,6 +3584,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::{
@@ -4302,4 +4381,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)
);
}
}
+80 -32
View File
@@ -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<PythonRequest>,
workspace: Option<&Workspace>,
script: Pep723ItemRef<'_>,
no_config: bool,
) -> Result<Self, ProjectError> {
// 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 {
+88 -36
View File
@@ -499,59 +499,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")?;
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())
"#
})?;
// 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 -----
");
// 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"), @r"
success: true
exit_code: 0
----- stdout -----
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")?;
// 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",
# ]
# ///
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
----- stdout -----
----- 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 <module>
x: str | int = "hello"
TypeError: unsupported operand type(s) for |: 'type' and 'type'
"#);
// Delete the `.python-version` file to allow the script to run.
fs_err::remove_file(&python_version)?;
uv_snapshot!(context.filters(), context.run().arg("main.py"), @"
// 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 -----
hello
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(())