From 34435d7d9dca2efecc03ad1a98e7e9a490a6aea0 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 30 Aug 2024 15:40:38 -0400 Subject: [PATCH] Error when discovered Python is incompatible with `--isolated` workspace (#6885) ## Summary We should have consistent errors with and without `--isolated`. --- crates/uv/src/commands/project/mod.rs | 70 ++++++++++++++++----------- crates/uv/src/commands/project/run.rs | 51 ++++++++++--------- crates/uv/tests/run.rs | 54 +++++++++++++++++++++ 3 files changed, 123 insertions(+), 52 deletions(-) diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 8e92b18f2..c5b10fa32 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -146,6 +146,46 @@ pub(crate) fn find_requires_python( })) } +/// Returns an error if the [`Interpreter`] does not satisfy the [`Workspace`] `requires-python`. +#[allow(clippy::result_large_err)] +pub(crate) fn validate_requires_python( + interpreter: &Interpreter, + workspace: &Workspace, + requires_python: &RequiresPython, +) -> Result<(), ProjectError> { + if !requires_python.contains(interpreter.python_version()) { + // If the Python version is compatible with one of the workspace _members_, raise + // a dedicated error. For example, if the workspace root requires Python >=3.12, but + // a library in the workspace is compatible with Python >=3.8, the user may attempt + // to sync on Python 3.8. This will fail, but we should provide a more helpful error + // message. + for (name, member) in workspace.packages() { + let Some(project) = member.pyproject_toml().project.as_ref() else { + continue; + }; + let Some(specifiers) = project.requires_python.as_ref() else { + continue; + }; + if specifiers.contains(interpreter.python_version()) { + return Err(ProjectError::RequestedMemberPythonIncompatibility( + interpreter.python_version().clone(), + requires_python.clone(), + name.clone(), + specifiers.clone(), + member.root().clone(), + )); + } + } + + return Err(ProjectError::RequestedPythonIncompatibility( + interpreter.python_version().clone(), + requires_python.clone(), + )); + } + + Ok(()) +} + /// Find the virtual environment for the current project. fn find_environment( workspace: &Workspace, @@ -297,35 +337,7 @@ impl FoundInterpreter { } if let Some(requires_python) = requires_python.as_ref() { - if !requires_python.contains(interpreter.python_version()) { - // If the Python version is compatible with one of the workspace _members_, raise - // a dedicated error. For example, if the workspace root requires Python >=3.12, but - // a library in the workspace is compatible with Python >=3.8, the user may attempt - // to sync on Python 3.8. This will fail, but we should provide a more helpful error - // message. - for (name, member) in workspace.packages() { - let Some(project) = member.pyproject_toml().project.as_ref() else { - continue; - }; - let Some(specifiers) = project.requires_python.as_ref() else { - continue; - }; - if specifiers.contains(interpreter.python_version()) { - return Err(ProjectError::RequestedMemberPythonIncompatibility( - interpreter.python_version().clone(), - requires_python.clone(), - name.clone(), - specifiers.clone(), - member.root().clone(), - )); - } - } - - return Err(ProjectError::RequestedPythonIncompatibility( - interpreter.python_version().clone(), - requires_python.clone(), - )); - } + validate_requires_python(&interpreter, workspace, requires_python)?; } Ok(Self::Interpreter(interpreter)) diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index a0d040aad..b645bda61 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -34,7 +34,7 @@ use crate::commands::pip::loggers::{ use crate::commands::pip::operations; use crate::commands::pip::operations::Modifications; use crate::commands::project::environment::CachedEnvironment; -use crate::commands::project::{ProjectError, WorkspacePython}; +use crate::commands::project::{validate_requires_python, ProjectError, WorkspacePython}; use crate::commands::reporters::PythonDownloadReporter; use crate::commands::{project, ExitStatus, SharedState}; use crate::printer::Printer; @@ -352,30 +352,35 @@ pub(crate) async fn run( // If we're isolating the environment, use an ephemeral virtual environment as the // base environment for the project. - let interpreter = { - let client_builder = BaseClientBuilder::new() - .connectivity(connectivity) - .native_tls(native_tls); + let client_builder = BaseClientBuilder::new() + .connectivity(connectivity) + .native_tls(native_tls); - // Resolve the Python request and requirement for the workspace. - let WorkspacePython { python_request, .. } = WorkspacePython::from_request( - python.as_deref().map(PythonRequest::parse), - project.workspace(), - ) - .await?; + // Resolve the Python request and requirement for the workspace. + let WorkspacePython { + python_request, + requires_python, + } = WorkspacePython::from_request( + python.as_deref().map(PythonRequest::parse), + project.workspace(), + ) + .await?; - PythonInstallation::find_or_download( - python_request.as_ref(), - EnvironmentPreference::Any, - python_preference, - python_downloads, - &client_builder, - cache, - Some(&download_reporter), - ) - .await? - .into_interpreter() - }; + let interpreter = PythonInstallation::find_or_download( + python_request.as_ref(), + EnvironmentPreference::Any, + python_preference, + python_downloads, + &client_builder, + cache, + Some(&download_reporter), + ) + .await? + .into_interpreter(); + + if let Some(requires_python) = requires_python.as_ref() { + validate_requires_python(&interpreter, project.workspace(), requires_python)?; + } // Create a virtual environment temp_dir = cache.environment()?; diff --git a/crates/uv/tests/run.rs b/crates/uv/tests/run.rs index ee0d30f10..462b7beb7 100644 --- a/crates/uv/tests/run.rs +++ b/crates/uv/tests/run.rs @@ -1617,3 +1617,57 @@ fn run_project_toml_error() -> Result<()> { Ok(()) } + +#[test] +fn run_isolated_incompatible_python() -> Result<()> { + let context = TestContext::new_with_versions(&["3.8", "3.11"]); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! { r#" + [project] + name = "foo" + version = "1.0.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + + [build-system] + requires = ["setuptools>=42", "wheel"] + build-backend = "setuptools.build_meta" + "# + })?; + + let python_version = context.temp_dir.child(PYTHON_VERSION_FILENAME); + python_version.write_str("3.8")?; + + let test_script = context.temp_dir.child("main.py"); + test_script.write_str(indoc! { r#" + import iniconfig + + x: str | int = "hello" + print(x) + "# + })?; + + // We should reject Python 3.8... + uv_snapshot!(context.filters(), context.run().arg("main.py"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Using Python 3.8.[X] interpreter at: [PYTHON-3.8] + error: The requested Python interpreter (3.8.[X]) is incompatible with the project Python requirement: `>=3.12` + "###); + + // ...even if `--isolated` is provided. + uv_snapshot!(context.filters(), context.run().arg("--isolated").arg("main.py"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: The requested Python interpreter (3.8.[X]) is incompatible with the project Python requirement: `>=3.12` + "###); + + Ok(()) +}