From 5b48cf1a0572cb7c1d8529ffa6935cb4f64c8b7a Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Wed, 21 Aug 2024 14:55:15 -0500 Subject: [PATCH] Improve interactions between `.python-version` files and project `requires-python` --- crates/uv/src/commands/project/mod.rs | 78 ++++++++++++++++++++++++++- crates/uv/src/commands/python/find.rs | 45 ++-------------- crates/uv/src/commands/python/pin.rs | 2 +- crates/uv/tests/python_find.rs | 72 ++++++++++++++++++++++--- 4 files changed, 147 insertions(+), 50 deletions(-) diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 9e8f1329b0c5..1107bd4451ff 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -14,7 +14,7 @@ use uv_client::{BaseClientBuilder, Connectivity, FlatIndexClient, RegistryClient use uv_configuration::{Concurrency, ExtrasSpecification, Reinstall, Upgrade}; use uv_dispatch::BuildDispatch; use uv_distribution::DistributionDatabase; -use uv_fs::Simplified; +use uv_fs::{Simplified, CWD}; use uv_installer::{SatisfiesResult, SitePackages}; use uv_normalize::PackageName; use uv_python::{ @@ -27,7 +27,7 @@ use uv_resolver::{ }; use uv_types::{BuildIsolation, EmptyInstalledPackages, HashStrategy}; use uv_warnings::{warn_user, warn_user_once}; -use uv_workspace::Workspace; +use uv_workspace::{DiscoveryOptions, VirtualProject, Workspace, WorkspaceError}; use crate::commands::pip::loggers::{InstallLogger, ResolveLogger}; use crate::commands::pip::operations::{Changelog, Modifications}; @@ -36,6 +36,8 @@ use crate::commands::{pip, SharedState}; use crate::printer::Printer; use crate::settings::{InstallerSettingsRef, ResolverInstallerSettings, ResolverSettingsRef}; +use super::python::pin::pep440_version_from_request; + pub(crate) mod add; pub(crate) mod environment; pub(crate) mod init; @@ -1047,3 +1049,75 @@ fn warn_on_requirements_txt_setting( warn_user_once!("Ignoring `--no-binary` setting from requirements file. Instead, use the `--no-build` command-line argument, or set `no-build` in a `uv.toml` or `pyproject.toml` file."); } } + +/// Determine the [`PythonRequest`] to use in a command, if any. +pub(crate) async fn find_python_request( + user_request: Option, + no_project: bool, + no_config: bool, +) -> Result, ProjectError> { + // (1) Explicit request from user + let mut request = user_request.map(|request| PythonRequest::parse(&request)); + + let (project, requires_python) = if no_project { + (None, None) + } else { + let project = match VirtualProject::discover(&CWD, &DiscoveryOptions::default()).await { + Ok(project) => Some(project), + Err(WorkspaceError::MissingProject(_)) => None, + Err(WorkspaceError::MissingPyprojectToml) => None, + Err(WorkspaceError::NonWorkspace(_)) => None, + Err(err) => { + warn_user_once!("{err}"); + None + } + }; + + let requires_python = if let Some(project) = project.as_ref() { + find_requires_python(project.workspace())? + } else { + None + }; + + (project, requires_python) + }; + + // (2) Request from a `.python-version` file + if request.is_none() { + let version_file = PythonVersionFile::discover(&*CWD, no_config).await?; + + let mut should_use = true; + if let Some(request) = version_file.as_ref().and_then(PythonVersionFile::version) { + // If we're in a project, make sure the request is compatible + if let Some(requires_python) = &requires_python { + if let Some(version) = pep440_version_from_request(request) { + if !requires_python.specifiers().contains(&version) { + let path = version_file.as_ref().unwrap().path(); + if path.starts_with(project.unwrap().root()) { + // It's the pin is declared _inside_ the project, just warn... + warn_user_once!("The pinned Python version ({version}) in `{}` does not meet the project's Python requirement of `{requires_python}`.", path.user_display().cyan()); + } else { + // Otherwise, we can just ignore the pin — it's outside the project + debug!("Ignoring pinned Python version ({version}) at `{}`, it does not meet the project's Python requirement of `{requires_python}`.", path.user_display().cyan()); + should_use = false; + } + } + } + } + } + + if should_use { + request = version_file.and_then(PythonVersionFile::into_version); + } + } + + // (3) The `requires-python` defined in `pyproject.toml` + if request.is_none() && !no_project { + request = requires_python + .as_ref() + .map(RequiresPython::specifiers) + .map(|specifiers| PythonRequest::Version(VersionRequest::Range(specifiers.clone()))); + } + + Ok(request) +} diff --git a/crates/uv/src/commands/python/find.rs b/crates/uv/src/commands/python/find.rs index d2f84052c0df..c0fe6e3c83f8 100644 --- a/crates/uv/src/commands/python/find.rs +++ b/crates/uv/src/commands/python/find.rs @@ -2,16 +2,10 @@ use anstream::println; use anyhow::Result; use uv_cache::Cache; -use uv_fs::{Simplified, CWD}; -use uv_python::{ - EnvironmentPreference, PythonInstallation, PythonPreference, PythonRequest, PythonVersionFile, - VersionRequest, -}; -use uv_resolver::RequiresPython; -use uv_warnings::warn_user_once; -use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceError}; +use uv_fs::Simplified; +use uv_python::{EnvironmentPreference, PythonInstallation, PythonPreference}; -use crate::commands::{project::find_requires_python, ExitStatus}; +use crate::commands::{project::find_python_request, ExitStatus}; /// Find a Python interpreter. pub(crate) async fn find( @@ -21,38 +15,7 @@ pub(crate) async fn find( python_preference: PythonPreference, cache: &Cache, ) -> Result { - // (1) Explicit request from user - let mut request = request.map(|request| PythonRequest::parse(&request)); - - // (2) Request from `.python-version` - if request.is_none() { - request = PythonVersionFile::discover(&*CWD, no_config) - .await? - .and_then(PythonVersionFile::into_version); - } - - // (3) `Requires-Python` in `pyproject.toml` - if request.is_none() && !no_project { - let project = match VirtualProject::discover(&CWD, &DiscoveryOptions::default()).await { - Ok(project) => Some(project), - Err(WorkspaceError::MissingProject(_)) => None, - Err(WorkspaceError::MissingPyprojectToml) => None, - Err(WorkspaceError::NonWorkspace(_)) => None, - Err(err) => { - warn_user_once!("{err}"); - None - } - }; - - if let Some(project) = project { - request = find_requires_python(project.workspace())? - .as_ref() - .map(RequiresPython::specifiers) - .map(|specifiers| { - PythonRequest::Version(VersionRequest::Range(specifiers.clone())) - }); - } - } + let request = find_python_request(request, no_project, no_config).await?; let python = PythonInstallation::find( &request.unwrap_or_default(), diff --git a/crates/uv/src/commands/python/pin.rs b/crates/uv/src/commands/python/pin.rs index df509fcddac5..74bd9b52cee3 100644 --- a/crates/uv/src/commands/python/pin.rs +++ b/crates/uv/src/commands/python/pin.rs @@ -153,7 +153,7 @@ pub(crate) async fn pin( Ok(ExitStatus::Success) } -fn pep440_version_from_request(request: &PythonRequest) -> Option { +pub(crate) fn pep440_version_from_request(request: &PythonRequest) -> Option { let version_request = match request { PythonRequest::Version(ref version) | PythonRequest::ImplementationVersion(_, ref version) => version, diff --git a/crates/uv/tests/python_find.rs b/crates/uv/tests/python_find.rs index ca184aaed9ac..3642990dd508 100644 --- a/crates/uv/tests/python_find.rs +++ b/crates/uv/tests/python_find.rs @@ -232,7 +232,7 @@ fn python_find_pin() { #[test] fn python_find_project() { - let context: TestContext = TestContext::new_with_versions(&["3.11", "3.12"]); + let context: TestContext = TestContext::new_with_versions(&["3.10", "3.11", "3.12"]); let pyproject_toml = context.temp_dir.child("pyproject.toml"); pyproject_toml @@ -240,7 +240,7 @@ fn python_find_project() { [project] name = "project" version = "0.1.0" - requires-python = ">=3.12" + requires-python = ">=3.11" dependencies = ["anyio==3.7.0"] "#}) .unwrap(); @@ -250,17 +250,17 @@ fn python_find_project() { success: true exit_code: 0 ----- stdout ----- - [PYTHON-3.12] + [PYTHON-3.11] ----- stderr ----- "###); // Unless explicitly requested - uv_snapshot!(context.filters(), context.python_find().arg("3.11"), @r###" + uv_snapshot!(context.filters(), context.python_find().arg("3.10"), @r###" success: true exit_code: 0 ----- stdout ----- - [PYTHON-3.11] + [PYTHON-3.10] ----- stderr ----- "###); @@ -270,7 +270,67 @@ fn python_find_project() { success: true exit_code: 0 ----- stdout ----- - [PYTHON-3.11] + [PYTHON-3.10] + + ----- stderr ----- + "###); + + // But a pin should take precedence + uv_snapshot!(context.filters(), context.python_pin().arg("3.12"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Pinned `.python-version` to `3.12` + + ----- stderr ----- + "###); + uv_snapshot!(context.filters(), context.python_find(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-3.12] + + ----- stderr ----- + "###); + + // And we should warn if it's not compatible with the project + 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` value of `>=3.11`. + "###); + uv_snapshot!(context.filters(), context.python_find(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-3.12] + + ----- stderr ----- + "###); + + // Unless the pin file is outside the project, in which case we can just ignore it + let child_dir = context.temp_dir.child("child"); + child_dir.create_dir_all().unwrap(); + + let pyproject_toml = child_dir.child("pyproject.toml"); + pyproject_toml + .write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.11" + dependencies = ["anyio==3.7.0"] + "#}) + .unwrap(); + + uv_snapshot!(context.filters(), context.python_find().current_dir(&child_dir), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-3.12] ----- stderr ----- "###);