Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Check python pin compatibility with Requires-Python #4989

Merged
merged 14 commits into from
Jul 19, 2024
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
Loading