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:
Ahmed Ilyas 2024-07-19 20:17:41 +02:00 committed by GitHub
parent 7762d78281
commit 3ee3db23d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 367 additions and 1 deletions

View File

@ -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
))
}

View File

@ -783,6 +783,7 @@ async fn run(cli: Cli) -> Result<ExitStatus> {
args.resolved,
globals.python_preference,
globals.preview,
globals.isolated,
&cache,
printer,
)

View File

@ -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() {