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:
Generated
+1
@@ -6883,6 +6883,7 @@ dependencies = [
|
||||
"uv-client",
|
||||
"uv-dirs",
|
||||
"uv-distribution-filename",
|
||||
"uv-distribution-types",
|
||||
"uv-extract",
|
||||
"uv-fs",
|
||||
"uv-install-wheel",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
|
||||
Reference in New Issue
Block a user