diff --git a/crates/uv/src/commands/python/pin.rs b/crates/uv/src/commands/python/pin.rs index eb0639b69..786bc63c7 100644 --- a/crates/uv/src/commands/python/pin.rs +++ b/crates/uv/src/commands/python/pin.rs @@ -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 { @@ -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 { + 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 + )) +} diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index f1be81a55..23a1340bb 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -783,6 +783,7 @@ async fn run(cli: Cli) -> Result { args.resolved, globals.python_preference, globals.preview, + globals.isolated, &cache, printer, ) diff --git a/crates/uv/tests/python_pin.rs b/crates/uv/tests/python_pin.rs index 08e00ec0d..53ca8fce7 100644 --- a/crates/uv/tests/python_pin.rs +++ b/crates/uv/tests/python_pin.rs @@ -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() {