Skip to content

Commit

Permalink
Check python pin compatibility with Requires-Python (#4989)
Browse files Browse the repository at this point in the history
## Summary

Resolves #4969 

## Test Plan

`cargo test` and manual tests.

---------

Co-authored-by: Zanie Blue <[email protected]>
  • Loading branch information
blueraft and zanieb authored Jul 19, 2024
1 parent 7762d78 commit 3ee3db2
Show file tree
Hide file tree
Showing 3 changed files with 367 additions and 1 deletion.
186 changes: 185 additions & 1 deletion crates/uv/src/commands/python/pin.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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.
Expand All @@ -22,18 +25,36 @@ pub(crate) async fn pin(
resolved: bool,
python_preference: PythonPreference,
preview: PreviewMode,
isolated: bool,
cache: &Cache,
printer: Printer,
) -> Result<ExitStatus> {
if preview.is_disabled() {
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);
}
Expand All @@ -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
Expand Down Expand Up @@ -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
))
}
1 change: 1 addition & 0 deletions crates/uv/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,7 @@ async fn run(cli: Cli) -> Result<ExitStatus> {
args.resolved,
globals.python_preference,
globals.preview,
globals.isolated,
&cache,
printer,
)
Expand Down
Loading

0 comments on commit 3ee3db2

Please sign in to comment.