diff --git a/crates/uv/src/commands/venv.rs b/crates/uv/src/commands/venv.rs index bc9ba04963c8..980c6825d5cd 100644 --- a/crates/uv/src/commands/venv.rs +++ b/crates/uv/src/commands/venv.rs @@ -20,16 +20,18 @@ use uv_configuration::{ NoBuild, PreviewMode, SetupPyStrategy, }; use uv_dispatch::BuildDispatch; -use uv_fs::Simplified; +use uv_fs::{Simplified, CWD}; use uv_python::{ request_from_version_file, EnvironmentPreference, PythonFetch, PythonInstallation, - PythonPreference, PythonRequest, + PythonPreference, PythonRequest, VersionRequest, }; -use uv_resolver::{ExcludeNewer, FlatIndex}; +use uv_resolver::{ExcludeNewer, FlatIndex, RequiresPython}; use uv_shell::Shell; use uv_types::{BuildContext, BuildIsolation, HashStrategy}; use uv_warnings::warn_user_once; +use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceError}; +use crate::commands::project::find_requires_python; use crate::commands::reporters::PythonDownloadReporter; use crate::commands::{pip, ExitStatus, SharedState}; use crate::printer::Printer; @@ -130,20 +132,42 @@ async fn venv_impl( printer: Printer, relocatable: bool, ) -> miette::Result { + if preview.is_disabled() && relocatable { + warn_user_once!("`--relocatable` is experimental and may change without warning"); + } + let client_builder = BaseClientBuilder::default() .connectivity(connectivity) .native_tls(native_tls); - let client_builder_clone = client_builder.clone(); - let reporter = PythonDownloadReporter::single(printer); + // (1) Explicit request from user let mut interpreter_request = python_request.map(PythonRequest::parse); + + // (2) Request from `.python-version` if preview.is_enabled() && interpreter_request.is_none() { interpreter_request = request_from_version_file().await.into_diagnostic()?; } - if preview.is_disabled() && relocatable { - warn_user_once!("`--relocatable` is experimental and may change without warning"); + + // (3) `Requires-Python` in `pyproject.toml` + if preview.is_enabled() && interpreter_request.is_none() { + let project = match VirtualProject::discover(&CWD, &DiscoveryOptions::default()).await { + Ok(project) => Some(project), + Err(WorkspaceError::MissingPyprojectToml) => None, + Err(WorkspaceError::NonWorkspace(_)) => None, + Err(err) => return Err(err).into_diagnostic(), + }; + + if let Some(project) = project { + interpreter_request = find_requires_python(project.workspace()) + .into_diagnostic()? + .as_ref() + .map(RequiresPython::specifiers) + .map(|specifiers| { + PythonRequest::Version(VersionRequest::Range(specifiers.clone())) + }); + } } // Locate the Python interpreter to use in the environment @@ -214,7 +238,7 @@ async fn venv_impl( } // Instantiate a client. - let client = RegistryClientBuilder::from(client_builder_clone) + let client = RegistryClientBuilder::from(client_builder) .cache(cache.clone()) .index_urls(index_locations.index_urls()) .index_strategy(index_strategy) diff --git a/crates/uv/tests/venv.rs b/crates/uv/tests/venv.rs index 1e3c1deef4a1..fef4d8200788 100644 --- a/crates/uv/tests/venv.rs +++ b/crates/uv/tests/venv.rs @@ -3,6 +3,7 @@ use anyhow::Result; use assert_cmd::prelude::*; use assert_fs::prelude::*; +use indoc::indoc; use uv_python::{PYTHON_VERSIONS_FILENAME, PYTHON_VERSION_FILENAME}; use crate::common::{uv_snapshot, TestContext}; @@ -171,6 +172,184 @@ fn create_venv_reads_request_from_python_versions_file() { context.venv.assert(predicates::path::is_dir()); } +#[test] +fn create_venv_respects_pyproject_requires_python() -> Result<()> { + let context = TestContext::new_with_versions(&["3.11", "3.9", "3.10", "3.12"]); + + // Without a Python requirement, we use the first on the PATH + uv_snapshot!(context.filters(), context.venv() + .arg("--preview"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using Python 3.11.[X] interpreter at: [PYTHON-3.11] + Creating virtualenv at: .venv + Activate with: source .venv/bin/activate + "# + ); + + // With `requires-python = "<3.11"`, we prefer the first available version + 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.11" + dependencies = [] + "# + })?; + + uv_snapshot!(context.filters(), context.venv() + .arg("--preview"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using Python 3.9.[X] interpreter at: [PYTHON-3.9] + Creating virtualenv at: .venv + Activate with: source .venv/bin/activate + "### + ); + + // With `requires-python = "==3.11.*"`, we prefer exact version (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.11.*" + dependencies = [] + "# + })?; + + uv_snapshot!(context.filters(), context.venv() + .arg("--preview"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using Python 3.11.[X] interpreter at: [PYTHON-3.11] + Creating virtualenv at: .venv + Activate with: source .venv/bin/activate + "# + ); + + // With `requires-python = ">=3.11,<3.12"`, we prefer exact version (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.11,<3.12" + dependencies = [] + "# + })?; + + uv_snapshot!(context.filters(), context.venv() + .arg("--preview"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using Python 3.11.[X] interpreter at: [PYTHON-3.11] + Creating virtualenv at: .venv + Activate with: source .venv/bin/activate + "# + ); + + // With `requires-python = ">=3.10"`, we prefer first compatible version (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.11" + dependencies = [] + "# + })?; + + // With `requires-python = ">=3.11"`, we prefer first compatible version (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.11" + dependencies = [] + "# + })?; + + uv_snapshot!(context.filters(), context.venv() + .arg("--preview"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using Python 3.11.[X] interpreter at: [PYTHON-3.11] + Creating virtualenv at: .venv + Activate with: source .venv/bin/activate + "# + ); + + // With `requires-python = ">3.11"`, we prefer first compatible version (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.11" + dependencies = [] + "# + })?; + + uv_snapshot!(context.filters(), context.venv() + .arg("--preview"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using Python 3.11.[X] interpreter at: [PYTHON-3.11] + Creating virtualenv at: .venv + Activate with: source .venv/bin/activate + "# + ); + + // With `requires-python = ">=3.12"`, we prefer first compatible version (3.12) + 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 = [] + "# + })?; + + uv_snapshot!(context.filters(), context.venv() + .arg("--preview"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using Python 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtualenv at: .venv + Activate with: source .venv/bin/activate + "# + ); + + context.venv.assert(predicates::path::is_dir()); + + Ok(()) +} + #[test] fn create_venv_explicit_request_takes_priority_over_python_version_file() { let context = TestContext::new_with_versions(&["3.11", "3.12"]);