mirror of https://github.com/astral-sh/uv
Check `python pin` compatibility with `Requires-Python` (#4989)
## Summary Resolves #4969 ## Test Plan `cargo test` and manual tests. --------- Co-authored-by: Zanie Blue <contact@zanie.dev>
This commit is contained in:
parent
7762d78281
commit
3ee3db23d6
|
|
@ -1,10 +1,13 @@
|
|||
use std::fmt::Write;
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use owo_colors::OwoColorize;
|
||||
|
||||
use tracing::debug;
|
||||
use uv_cache::Cache;
|
||||
use uv_configuration::PreviewMode;
|
||||
use uv_distribution::VirtualProject;
|
||||
use uv_fs::Simplified;
|
||||
use uv_python::{
|
||||
request_from_version_file, requests_from_version_file, write_version_file,
|
||||
|
|
@ -13,7 +16,7 @@ use uv_python::{
|
|||
};
|
||||
use uv_warnings::warn_user_once;
|
||||
|
||||
use crate::commands::ExitStatus;
|
||||
use crate::commands::{project::find_requires_python, ExitStatus};
|
||||
use crate::printer::Printer;
|
||||
|
||||
/// Pin to a specific Python version.
|
||||
|
|
@ -22,6 +25,7 @@ pub(crate) async fn pin(
|
|||
resolved: bool,
|
||||
python_preference: PythonPreference,
|
||||
preview: PreviewMode,
|
||||
isolated: bool,
|
||||
cache: &Cache,
|
||||
printer: Printer,
|
||||
) -> Result<ExitStatus> {
|
||||
|
|
@ -29,11 +33,28 @@ pub(crate) async fn pin(
|
|||
warn_user_once!("`uv python pin` is experimental and may change without warning");
|
||||
}
|
||||
|
||||
let virtual_project = match VirtualProject::discover(&std::env::current_dir()?, None).await {
|
||||
Ok(virtual_project) if !isolated => Some(virtual_project),
|
||||
Ok(_) => None,
|
||||
Err(err) => {
|
||||
debug!("Failed to discover virtual project: {err}");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let Some(request) = request else {
|
||||
// Display the current pinned Python version
|
||||
if let Some(pins) = requests_from_version_file().await? {
|
||||
for pin in pins {
|
||||
writeln!(printer.stdout(), "{}", pin.to_canonical_string())?;
|
||||
if let Some(virtual_project) = &virtual_project {
|
||||
warn_if_existing_pin_incompatible_with_project(
|
||||
&pin,
|
||||
virtual_project,
|
||||
python_preference,
|
||||
cache,
|
||||
);
|
||||
}
|
||||
}
|
||||
return Ok(ExitStatus::Success);
|
||||
}
|
||||
|
|
@ -56,6 +77,38 @@ pub(crate) async fn pin(
|
|||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
|
||||
if let Some(virtual_project) = &virtual_project {
|
||||
if let Some(request_version) = pep440_version_from_request(&request) {
|
||||
assert_pin_compatible_with_project(
|
||||
&Pin {
|
||||
request: &request,
|
||||
version: &request_version,
|
||||
resolved: false,
|
||||
existing: false,
|
||||
},
|
||||
virtual_project,
|
||||
)?;
|
||||
} else {
|
||||
if let Some(python) = &python {
|
||||
// Warn if the resolved Python is incompatible with the Python requirement unless --resolved is used
|
||||
if let Err(err) = assert_pin_compatible_with_project(
|
||||
&Pin {
|
||||
request: &request,
|
||||
version: python.python_version(),
|
||||
resolved: true,
|
||||
existing: false,
|
||||
},
|
||||
virtual_project,
|
||||
) {
|
||||
if resolved {
|
||||
return Err(err);
|
||||
};
|
||||
warn_user_once!("{}", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let output = if resolved {
|
||||
// SAFETY: We exit early if Python is not found and resolved is `true`
|
||||
python
|
||||
|
|
@ -93,3 +146,134 @@ pub(crate) async fn pin(
|
|||
|
||||
Ok(ExitStatus::Success)
|
||||
}
|
||||
|
||||
fn pep440_version_from_request(request: &PythonRequest) -> Option<pep440_rs::Version> {
|
||||
let version_request = match request {
|
||||
PythonRequest::Version(ref version)
|
||||
| PythonRequest::ImplementationVersion(_, ref 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`.
|
||||
Some(pep440_rs::Version::from_str(&version_request.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,
|
||||
virtual_project: &VirtualProject,
|
||||
python_preference: PythonPreference,
|
||||
cache: &Cache,
|
||||
) {
|
||||
// Check if the pinned version is compatible with the project.
|
||||
if let Some(pin_version) = pep440_version_from_request(pin) {
|
||||
if let Err(err) = assert_pin_compatible_with_project(
|
||||
&Pin {
|
||||
request: pin,
|
||||
version: &pin_version,
|
||||
resolved: false,
|
||||
existing: true,
|
||||
},
|
||||
virtual_project,
|
||||
) {
|
||||
warn_user_once!("{}", err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If the there is not a version in the pinned request, attempt to resolve the pin into an interpreter
|
||||
// to check for compatibility on the current system.
|
||||
match PythonInstallation::find(
|
||||
pin,
|
||||
EnvironmentPreference::OnlySystem,
|
||||
python_preference,
|
||||
cache,
|
||||
) {
|
||||
Ok(python) => {
|
||||
let python_version = python.python_version();
|
||||
debug!(
|
||||
"The pinned Python version `{}` resolves to `{}`",
|
||||
pin, python_version
|
||||
);
|
||||
// Warn on incompatibilities when viewing existing pins
|
||||
if let Err(err) = assert_pin_compatible_with_project(
|
||||
&Pin {
|
||||
request: pin,
|
||||
version: python_version,
|
||||
resolved: true,
|
||||
existing: true,
|
||||
},
|
||||
virtual_project,
|
||||
) {
|
||||
warn_user_once!("{}", err);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn_user_once!(
|
||||
"Failed to resolve pinned Python version `{}`: {}",
|
||||
pin.to_canonical_string(),
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Utility struct for representing pins in error messages.
|
||||
struct Pin<'a> {
|
||||
request: &'a PythonRequest,
|
||||
version: &'a pep440_rs::Version,
|
||||
resolved: bool,
|
||||
existing: bool,
|
||||
}
|
||||
|
||||
/// Checks if the pinned Python version is compatible with the workspace/project's `Requires-Python`.
|
||||
fn assert_pin_compatible_with_project(pin: &Pin, virtual_project: &VirtualProject) -> Result<()> {
|
||||
let (requires_python, project_type) = match virtual_project {
|
||||
VirtualProject::Project(project_workspace) => {
|
||||
debug!(
|
||||
"Discovered project `{}` at: {}",
|
||||
project_workspace.project_name(),
|
||||
project_workspace.workspace().install_path().display()
|
||||
);
|
||||
let requires_python = find_requires_python(project_workspace.workspace())?;
|
||||
(requires_python, "project")
|
||||
}
|
||||
VirtualProject::Virtual(workspace) => {
|
||||
debug!(
|
||||
"Discovered virtual workspace at: {}",
|
||||
workspace.install_path().display()
|
||||
);
|
||||
let requires_python = find_requires_python(workspace)?;
|
||||
(requires_python, "workspace")
|
||||
}
|
||||
};
|
||||
|
||||
let Some(requires_python) = requires_python else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if requires_python.contains(pin.version) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let given = if pin.existing { "pinned" } else { "requested" };
|
||||
let resolved = if pin.resolved {
|
||||
format!(" resolves to `{}` which ", pin.version)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
Err(anyhow::anyhow!(
|
||||
"The {given} Python version `{}`{resolved} is incompatible with the {} `Requires-Python` requirement of `{}`.",
|
||||
pin.request.to_canonical_string(),
|
||||
project_type,
|
||||
requires_python
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -783,6 +783,7 @@ async fn run(cli: Cli) -> Result<ExitStatus> {
|
|||
args.resolved,
|
||||
globals.python_preference,
|
||||
globals.preview,
|
||||
globals.isolated,
|
||||
&cache,
|
||||
printer,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#![cfg(all(feature = "python", feature = "pypi"))]
|
||||
|
||||
use assert_fs::fixture::{FileWriteStr as _, PathChild as _};
|
||||
use common::{uv_snapshot, TestContext};
|
||||
use insta::assert_snapshot;
|
||||
use uv_python::{
|
||||
|
|
@ -229,6 +230,186 @@ fn python_pin_no_python() {
|
|||
"###);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_pin_compatible_with_requires_python() -> anyhow::Result<()> {
|
||||
let context: TestContext = TestContext::new_with_versions(&["3.10", "3.11"]);
|
||||
let pyproject_toml = context.temp_dir.child("pyproject.toml");
|
||||
pyproject_toml.write_str(
|
||||
r#"
|
||||
[project]
|
||||
name = "project"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = ["iniconfig"]
|
||||
"#,
|
||||
)?;
|
||||
|
||||
uv_snapshot!(context.filters(), context.python_pin().arg("3.10"), @r###"
|
||||
success: false
|
||||
exit_code: 2
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
error: The requested Python version `3.10` is incompatible with the project `Requires-Python` requirement of `>=3.11`.
|
||||
"###);
|
||||
|
||||
// Request a implementation version that is incompatible
|
||||
uv_snapshot!(context.filters(), context.python_pin().arg("cpython@3.10"), @r###"
|
||||
success: false
|
||||
exit_code: 2
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
error: The requested Python version `cpython@3.10` is incompatible with the project `Requires-Python` requirement of `>=3.11`.
|
||||
"###);
|
||||
|
||||
// Request a complex version range that resolves to an incompatible version
|
||||
uv_snapshot!(context.filters(), context.python_pin().arg(">3.8,<3.11"), @r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
Pinned `.python-version` to `>3.8, <3.11`
|
||||
|
||||
----- stderr -----
|
||||
warning: The requested Python version `>3.8, <3.11` resolves to `3.10.[X]` which is incompatible with the project `Requires-Python` requirement of `>=3.11`.
|
||||
"###);
|
||||
|
||||
uv_snapshot!(context.filters(), context.python_pin().arg("3.11"), @r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
Updated `.python-version` from `>3.8, <3.11` -> `3.11`
|
||||
|
||||
----- stderr -----
|
||||
"###);
|
||||
|
||||
// Request a implementation version that is compatible
|
||||
uv_snapshot!(context.filters(), context.python_pin().arg("cpython@3.11"), @r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
Updated `.python-version` from `3.11` -> `cpython@3.11`
|
||||
|
||||
----- stderr -----
|
||||
"###);
|
||||
|
||||
let python_version =
|
||||
fs_err::read_to_string(context.temp_dir.join(PYTHON_VERSION_FILENAME)).unwrap();
|
||||
insta::with_settings!({
|
||||
filters => context.filters(),
|
||||
}, {
|
||||
assert_snapshot!(python_version, @r###"
|
||||
cpython@3.11
|
||||
"###);
|
||||
});
|
||||
|
||||
// Updating `requires-python` should affect `uv python pin` compatibilities.
|
||||
pyproject_toml.write_str(
|
||||
r#"
|
||||
[project]
|
||||
name = "project"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = ["iniconfig"]
|
||||
"#,
|
||||
)?;
|
||||
|
||||
uv_snapshot!(context.filters(), context.python_pin(), @r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
cpython@3.11
|
||||
|
||||
----- stderr -----
|
||||
warning: The pinned Python version `cpython@3.11` is incompatible with the project `Requires-Python` requirement of `>=3.12`.
|
||||
"###);
|
||||
|
||||
// Request a implementation that resolves to a compatible version
|
||||
uv_snapshot!(context.filters(), context.python_pin().arg("cpython"), @r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
Updated `.python-version` from `cpython@3.11` -> `cpython`
|
||||
|
||||
----- stderr -----
|
||||
warning: The requested Python version `cpython` resolves to `3.10.[X]` which is incompatible with the project `Requires-Python` requirement of `>=3.12`.
|
||||
"###);
|
||||
|
||||
uv_snapshot!(context.filters(), context.python_pin(), @r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
cpython
|
||||
|
||||
----- stderr -----
|
||||
warning: The pinned Python version `cpython` resolves to `3.10.[X]` which is incompatible with the project `Requires-Python` requirement of `>=3.12`.
|
||||
"###);
|
||||
|
||||
// Request a complex version range that resolves to a compatible version
|
||||
uv_snapshot!(context.filters(), context.python_pin().arg(">3.8,<3.12"), @r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
Updated `.python-version` from `cpython` -> `>3.8, <3.12`
|
||||
|
||||
----- stderr -----
|
||||
warning: The requested Python version `>3.8, <3.12` resolves to `3.10.[X]` which is incompatible with the project `Requires-Python` requirement of `>=3.12`.
|
||||
"###);
|
||||
|
||||
uv_snapshot!(context.filters(), context.python_pin(), @r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
>3.8, <3.12
|
||||
|
||||
----- stderr -----
|
||||
warning: The pinned Python version `>3.8, <3.12` resolves to `3.10.[X]` which is incompatible with the project `Requires-Python` requirement of `>=3.12`.
|
||||
"###);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warning_pinned_python_version_not_installed() -> anyhow::Result<()> {
|
||||
let context: TestContext = TestContext::new_with_versions(&["3.10", "3.11"]);
|
||||
let pyproject_toml = context.temp_dir.child("pyproject.toml");
|
||||
pyproject_toml.write_str(
|
||||
r#"
|
||||
[project]
|
||||
name = "project"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = ["iniconfig"]
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let python_version_file = context.temp_dir.child(PYTHON_VERSION_FILENAME);
|
||||
python_version_file.write_str(r"3.12")?;
|
||||
if cfg!(windows) {
|
||||
uv_snapshot!(context.filters(), context.python_pin(), @r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
3.12
|
||||
|
||||
----- stderr -----
|
||||
warning: Failed to resolve pinned Python version `3.12`: No interpreter found for Python 3.12 in system path or `py` launcher
|
||||
"###);
|
||||
} else {
|
||||
uv_snapshot!(context.filters(), context.python_pin(), @r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
3.12
|
||||
|
||||
----- stderr -----
|
||||
warning: Failed to resolve pinned Python version `3.12`: No interpreter found for Python 3.12 in system path
|
||||
"###);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// We do need a Python interpreter for `--resolved` pins
|
||||
#[test]
|
||||
fn python_pin_resolve_no_python() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue