diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 192692b95625..1bfd4f383439 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -3031,6 +3031,13 @@ pub struct PythonFindArgs { /// /// See `uv help python` to view supported request formats. pub request: Option, + + /// Avoid discovering a project or workspace. + /// + /// Otherwise, when no request is provided, the Python requirement of a project in the current + /// directory or parent directories will be used. + #[arg(long, alias = "no_workspace")] + pub no_project: bool, } #[derive(Args)] diff --git a/crates/uv/src/commands/python/find.rs b/crates/uv/src/commands/python/find.rs index 5cc6a90a649e..d2f84052c0df 100644 --- a/crates/uv/src/commands/python/find.rs +++ b/crates/uv/src/commands/python/find.rs @@ -2,23 +2,60 @@ use anstream::println; use anyhow::Result; use uv_cache::Cache; -use uv_fs::Simplified; -use uv_python::{EnvironmentPreference, PythonInstallation, PythonPreference, PythonRequest}; +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 crate::commands::ExitStatus; +use crate::commands::{project::find_requires_python, ExitStatus}; /// Find a Python interpreter. pub(crate) async fn find( request: Option, + no_project: bool, + no_config: bool, python_preference: PythonPreference, cache: &Cache, ) -> Result { - let request = match request { - Some(request) => PythonRequest::parse(&request), - None => PythonRequest::Any, - }; + // (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 python = PythonInstallation::find( - &request, + &request.unwrap_or_default(), EnvironmentPreference::OnlySystem, python_preference, cache, diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 38a502cb7236..be49fcadc7e0 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -916,7 +916,14 @@ async fn run(cli: Cli) -> Result { // Initialize the cache. let cache = cache.init()?; - commands::python_find(args.request, globals.python_preference, &cache).await + commands::python_find( + args.request, + args.no_project, + cli.no_config, + globals.python_preference, + &cache, + ) + .await } Commands::Python(PythonNamespace { command: PythonCommand::Pin(args), diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 7db2d430e1d3..fcbb3895c350 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -564,15 +564,22 @@ impl PythonUninstallSettings { #[derive(Debug, Clone)] pub(crate) struct PythonFindSettings { pub(crate) request: Option, + pub(crate) no_project: bool, } impl PythonFindSettings { /// Resolve the [`PythonFindSettings`] from the CLI and workspace configuration. #[allow(clippy::needless_pass_by_value)] pub(crate) fn resolve(args: PythonFindArgs, _filesystem: Option) -> Self { - let PythonFindArgs { request } = args; + let PythonFindArgs { + request, + no_project, + } = args; - Self { request } + Self { + request, + no_project, + } } } diff --git a/crates/uv/tests/python_find.rs b/crates/uv/tests/python_find.rs index fb2cd9b7526e..7b27406a4338 100644 --- a/crates/uv/tests/python_find.rs +++ b/crates/uv/tests/python_find.rs @@ -1,5 +1,9 @@ #![cfg(all(feature = "python", feature = "pypi"))] +use assert_fs::fixture::FileWriteStr; +use assert_fs::prelude::PathChild; +use indoc::indoc; + use common::{uv_snapshot, TestContext}; use uv_python::platform::{Arch, Os}; @@ -148,3 +152,94 @@ fn python_find() { ----- stderr ----- "###); } + +#[test] +fn python_find_pin() { + let context: TestContext = TestContext::new_with_versions(&["3.11", "3.12"]); + + // Pin to a version + uv_snapshot!(context.filters(), context.python_pin().arg("3.12"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Pinned `.python-version` to `3.12` + + ----- stderr ----- + "###); + + // We should find the pinned version, not the first on the path + uv_snapshot!(context.filters(), context.python_find(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-3.12] + + ----- stderr ----- + "###); + + // Unless explicitly requested + uv_snapshot!(context.filters(), context.python_find().arg("3.11"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-3.11] + + ----- stderr ----- + "###); + + // Or `--no-config` is used + uv_snapshot!(context.filters(), context.python_find().arg("--no-config"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-3.11] + + ----- stderr ----- + "###); +} + +#[test] +fn python_find_project() { + let context: TestContext = TestContext::new_with_versions(&["3.11", "3.12"]); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml + .write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["anyio==3.7.0"] + "#}) + .unwrap(); + + // We should respect the project's required version, not the first on the path + uv_snapshot!(context.filters(), context.python_find(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-3.12] + + ----- stderr ----- + "###); + + // Unless explicitly requested + uv_snapshot!(context.filters(), context.python_find().arg("3.11"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-3.11] + + ----- stderr ----- + "###); + + // Or `--no-project` is used + uv_snapshot!(context.filters(), context.python_find().arg("--no-project"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-3.11] + + ----- stderr ----- + "###); +}