diff --git a/crates/uv-toolchain/src/lib.rs b/crates/uv-toolchain/src/lib.rs index 7927a165fce13..e02f5425790a4 100644 --- a/crates/uv-toolchain/src/lib.rs +++ b/crates/uv-toolchain/src/lib.rs @@ -6,6 +6,7 @@ pub use crate::discovery::{ ToolchainSources, VersionRequest, }; pub use crate::environment::PythonEnvironment; +pub use crate::implementation::ImplementationName; pub use crate::interpreter::Interpreter; pub use crate::pointer_size::PointerSize; pub use crate::prefix::Prefix; diff --git a/crates/uv-toolchain/src/toolchain.rs b/crates/uv-toolchain/src/toolchain.rs index e3b5d300b444f..ac458de2b3c85 100644 --- a/crates/uv-toolchain/src/toolchain.rs +++ b/crates/uv-toolchain/src/toolchain.rs @@ -33,7 +33,8 @@ impl Toolchain { cache: &Cache, ) -> Result { if let Some(python) = python { - Self::find_requested(python, system, preview, cache) + let request = ToolchainRequest::parse(python); + Self::find_requested(&request, system, preview, cache) } else if system.is_preferred() { Self::find_default(preview, cache) } else { @@ -60,14 +61,13 @@ impl Toolchain { /// Find an installed [`Toolchain`] that satisfies a request. pub fn find_requested( - request: &str, + request: &ToolchainRequest, system: SystemPython, preview: PreviewMode, cache: &Cache, ) -> Result { let sources = ToolchainSources::from_settings(system, preview); - let request = ToolchainRequest::parse(request); - let toolchain = find_toolchain(&request, system, &sources, cache)??; + let toolchain = find_toolchain(request, system, &sources, cache)??; Ok(toolchain) } diff --git a/crates/uv/src/cli.rs b/crates/uv/src/cli.rs index c8e0806720d43..e72486c76ac0b 100644 --- a/crates/uv/src/cli.rs +++ b/crates/uv/src/cli.rs @@ -1991,6 +1991,10 @@ pub(crate) enum ToolchainCommand { /// Download and install a specific toolchain. Install(ToolchainInstallArgs), + + /// Search for a toolchain + #[command(disable_version_flag = true)] + Find(ToolchainFindArgs), } #[derive(Args)] @@ -2014,6 +2018,18 @@ pub(crate) struct ToolchainInstallArgs { pub(crate) target: Option, } +#[derive(Args)] +#[allow(clippy::struct_excessive_bools)] +pub(crate) struct ToolchainFindArgs { + /// The version to find. + #[arg(long)] + pub(crate) version: Option, + + /// The implementation to find. + #[arg(long)] + pub(crate) implementation: Option, +} + #[derive(Args)] pub(crate) struct IndexArgs { /// The URL of the Python package index (by default: ). diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index f83605e328664..323eae7c5eefb 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -22,6 +22,7 @@ pub(crate) use project::sync::sync; #[cfg(feature = "self-update")] pub(crate) use self_update::self_update; pub(crate) use tool::run::run as run_tool; +pub(crate) use toolchain::find::find as toolchain_find; pub(crate) use toolchain::install::install as toolchain_install; pub(crate) use toolchain::list::list as toolchain_list; use uv_cache::Cache; diff --git a/crates/uv/src/commands/pip/compile.rs b/crates/uv/src/commands/pip/compile.rs index 6d1aa3eff1b41..24c3b0d2a3378 100644 --- a/crates/uv/src/commands/pip/compile.rs +++ b/crates/uv/src/commands/pip/compile.rs @@ -161,7 +161,8 @@ pub(crate) async fn pip_compile( SystemPython::Allowed }; let interpreter = if let Some(python) = python.as_ref() { - Toolchain::find_requested(python, system, preview, &cache) + let request = ToolchainRequest::parse(python); + Toolchain::find_requested(&request, system, preview, &cache) } else { // TODO(zanieb): The split here hints at a problem with the abstraction; we should be able to use // `Toolchain::find(...)` here. diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 67b468b21fa42..6ac5ff0afc4f7 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -49,7 +49,8 @@ pub(crate) async fn lock( if request.satisfied(&interpreter, cache) { interpreter } else { - Toolchain::find_requested(python, SystemPython::Allowed, preview, cache)? + let request = ToolchainRequest::parse(python); + Toolchain::find_requested(&request, SystemPython::Allowed, preview, cache)? .into_interpreter() } } else { diff --git a/crates/uv/src/commands/toolchain/find.rs b/crates/uv/src/commands/toolchain/find.rs new file mode 100644 index 0000000000000..662a84c95b2a0 --- /dev/null +++ b/crates/uv/src/commands/toolchain/find.rs @@ -0,0 +1,59 @@ +use anyhow::Result; +use std::fmt::Write; +use std::str::FromStr; + +use uv_cache::Cache; +use uv_configuration::PreviewMode; +use uv_fs::Simplified; +use uv_toolchain::{ImplementationName, SystemPython, Toolchain, ToolchainRequest, VersionRequest}; +use uv_warnings::warn_user; + +use crate::commands::ExitStatus; +use crate::printer::Printer; + +/// Find a toolchain. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn find( + version: Option, + implementation: Option, + preview: PreviewMode, + cache: &Cache, + printer: Printer, +) -> Result { + if preview.is_disabled() { + warn_user!("`uv toolchain find` is experimental and may change without warning."); + } + + let implementation = implementation + .as_deref() + .map(ImplementationName::from_str) + .transpose()?; + let version = version + .as_deref() + .map(VersionRequest::from_str) + .transpose()?; + + let request = match (version, implementation) { + (None, None) => ToolchainRequest::Any, + (Some(version), None) => ToolchainRequest::Version(version), + (Some(version), Some(implementation)) => { + ToolchainRequest::ImplementationVersion(implementation, version) + } + (None, Some(implementation)) => ToolchainRequest::Implementation(implementation), + }; + + let toolchain = Toolchain::find_requested( + &request, + SystemPython::Required, + PreviewMode::Enabled, + cache, + )?; + + writeln!( + printer.stdout(), + "{}", + toolchain.interpreter().sys_executable().user_display() + )?; + + Ok(ExitStatus::Success) +} diff --git a/crates/uv/src/commands/toolchain/mod.rs b/crates/uv/src/commands/toolchain/mod.rs index ef5a37748dc7a..772c9c26e96fd 100644 --- a/crates/uv/src/commands/toolchain/mod.rs +++ b/crates/uv/src/commands/toolchain/mod.rs @@ -1,2 +1,3 @@ +pub(crate) mod find; pub(crate) mod install; pub(crate) mod list; diff --git a/crates/uv/src/main.rs b/crates/uv/src/main.rs index c9134016810f9..7ddff0aad6da9 100644 --- a/crates/uv/src/main.rs +++ b/crates/uv/src/main.rs @@ -703,6 +703,24 @@ async fn run() -> Result { ) .await } + Commands::Toolchain(ToolchainNamespace { + command: ToolchainCommand::Find(args), + }) => { + // Resolve the settings from the command-line arguments and workspace configuration. + let args = settings::ToolchainFindSettings::resolve(args, workspace); + + // Initialize the cache. + let cache = cache.init()?; + + commands::toolchain_find( + args.version, + args.implementation, + globals.preview, + &cache, + printer, + ) + .await + } } } diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index b625e8520cac5..e6b44d73b254a 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -23,7 +23,7 @@ use uv_workspace::{Combine, PipOptions, Workspace}; use crate::cli::{ ColorChoice, GlobalArgs, LockArgs, Maybe, PipCheckArgs, PipCompileArgs, PipFreezeArgs, PipInstallArgs, PipListArgs, PipShowArgs, PipSyncArgs, PipUninstallArgs, RunArgs, SyncArgs, - ToolRunArgs, ToolchainInstallArgs, ToolchainListArgs, VenvArgs, + ToolRunArgs, ToolchainFindArgs, ToolchainInstallArgs, ToolchainListArgs, VenvArgs, }; use crate::commands::ListFormat; @@ -266,7 +266,7 @@ impl ToolchainListSettings { } } -/// The resolved settings to use for a `toolchain fetch` invocation. +/// The resolved settings to use for a `toolchain install` invocation. #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone)] pub(crate) struct ToolchainInstallSettings { @@ -283,6 +283,30 @@ impl ToolchainInstallSettings { } } +/// The resolved settings to use for a `toolchain find` invocation. +#[allow(clippy::struct_excessive_bools)] +#[derive(Debug, Clone)] +pub(crate) struct ToolchainFindSettings { + pub(crate) version: Option, + pub(crate) implementation: Option, +} + +impl ToolchainFindSettings { + /// Resolve the [`ToolchainFindSettings`] from the CLI and workspace configuration. + #[allow(clippy::needless_pass_by_value)] + pub(crate) fn resolve(args: ToolchainFindArgs, _workspace: Option) -> Self { + let ToolchainFindArgs { + version, + implementation, + } = args; + + Self { + version, + implementation, + } + } +} + /// The resolved settings to use for a `sync` invocation. #[allow(clippy::struct_excessive_bools, dead_code)] #[derive(Debug, Clone)] diff --git a/crates/uv/tests/common/mod.rs b/crates/uv/tests/common/mod.rs index 8f6aa7e9b5be4..caa0ceb05c032 100644 --- a/crates/uv/tests/common/mod.rs +++ b/crates/uv/tests/common/mod.rs @@ -4,7 +4,7 @@ use assert_cmd::assert::{Assert, OutputAssertExt}; use assert_cmd::Command; use assert_fs::assert::PathAssert; -use assert_fs::fixture::PathChild; +use assert_fs::fixture::{ChildPath, PathChild}; use regex::Regex; use std::borrow::BorrowMut; use std::env; @@ -274,6 +274,34 @@ impl TestContext { command } + pub fn toolchains_dir(&self) -> ChildPath { + self.temp_dir.child("toolchains") + } + + /// Create a `uv toolchain find` command with options shared across scenarios. + pub fn toolchain_find(&self) -> std::process::Command { + let mut command = std::process::Command::new(get_bin()); + command + .arg("toolchain") + .arg("find") + .arg("--cache-dir") + .arg(self.cache_dir.path()) + .env("VIRTUAL_ENV", self.venv.as_os_str()) + .env("UV_NO_WRAP", "1") + .env("UV_TEST_PYTHON_PATH", "/dev/null") + .env("UV_PREVIEW", "1") + .env("UV_TOOLCHAIN_DIR", self.toolchains_dir().as_os_str()) + .current_dir(&self.temp_dir); + + if cfg!(all(windows, debug_assertions)) { + // TODO(konstin): Reduce stack usage in debug mode enough that the tests pass with the + // default windows stack of 1MB + command.env("UV_STACK_SIZE", (4 * 1024 * 1024).to_string()); + } + + command + } + /// Create a `uv run` command with options shared across scenarios. pub fn run(&self) -> std::process::Command { let mut command = self.run_without_exclude_newer(); diff --git a/crates/uv/tests/toolchain_find.rs b/crates/uv/tests/toolchain_find.rs new file mode 100644 index 0000000000000..b8525f990e410 --- /dev/null +++ b/crates/uv/tests/toolchain_find.rs @@ -0,0 +1,182 @@ +#![cfg(all(feature = "python", feature = "pypi"))] + +use anyhow::Result; + +use common::{python_path_with_versions, uv_snapshot, TestContext}; + +mod common; + +#[test] +fn toolchain_find() { + let context: TestContext = TestContext::new("3.12"); + + // No interpreters on the path + uv_snapshot!(context.filters(), context.toolchain_find(), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No Python interpreters found in provided path, active virtual environment, or search path + "###); + + let python_path = python_path_with_versions(&context.temp_dir, &["3.11", "3.12"]) + .expect("Failed to create Python test path"); + + // Create some filters for the test interpreters, otherwise they'll be a path on the dev's machine + // TODO(zanieb): Standardize this when writing more tests + let python_path_filters = std::env::split_paths(&python_path) + .zip(["3.11", "3.12"]) + .flat_map(|(path, version)| { + TestContext::path_patterns(path) + .into_iter() + .map(move |pattern| { + ( + format!("{pattern}python.*"), + format!("[PYTHON-PATH-{version}]"), + ) + }) + }) + .collect::>(); + + let filters = python_path_filters + .iter() + .map(|(pattern, replacement)| (pattern.as_str(), replacement.as_str())) + .chain(context.filters()) + .collect::>(); + + // We find the first interpreter on the path + uv_snapshot!(filters, context.toolchain_find() + .env("UV_TEST_PYTHON_PATH", &python_path), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-PATH-3.11] + + ----- stderr ----- + "###); + + // Request Python 3.12 + uv_snapshot!(filters, context.toolchain_find() + .arg("--version") + .arg("3.12") + .env("UV_TEST_PYTHON_PATH", &python_path), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-PATH-3.12] + + ----- stderr ----- + "###); + + // Request Python 3.11 + uv_snapshot!(filters, context.toolchain_find() + .arg("--version") + .arg("3.11") + .env("UV_TEST_PYTHON_PATH", &python_path), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-PATH-3.11] + + ----- stderr ----- + "###); + + // Request CPython + uv_snapshot!(filters, context.toolchain_find() + .arg("--implementation") + .arg("cpython") + .env("UV_TEST_PYTHON_PATH", &python_path), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-PATH-3.11] + + ----- stderr ----- + "###); + + // Request CPython 3.12 + uv_snapshot!(filters, context.toolchain_find() + .arg("--implementation") + .arg("cpython") + .arg("--version") + .arg("3.12") + .env("UV_TEST_PYTHON_PATH", &python_path), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-PATH-3.12] + + ----- stderr ----- + "###); + + // Request PyPy + uv_snapshot!(filters, context.toolchain_find() + .arg("--implementation") + .arg("pypy") + .env("UV_TEST_PYTHON_PATH", &python_path), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No interpreter found for PyPy in provided path, active virtual environment, or search path + "###); + + // Swap the order (but don't change the filters to preserve our indices) + let python_path = python_path_with_versions(&context.temp_dir, &["3.12", "3.11"]) + .expect("Failed to create Python test path"); + + uv_snapshot!(filters, context.toolchain_find() + .env("UV_TEST_PYTHON_PATH", &python_path), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-PATH-3.12] + + ----- stderr ----- + "###); + + // Request Python 3.11 + uv_snapshot!(filters, context.toolchain_find() + .arg("--version") + .arg("3.11") + .env("UV_TEST_PYTHON_PATH", &python_path), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-PATH-3.11] + + ----- stderr ----- + "###); +} + +#[test] +fn toolchain_find_invalid_implementation() { + let context = TestContext::new("3.12"); + + // No interpreters on the path + uv_snapshot!(context.filters(), context.toolchain_find().arg("--implementation").arg("foobar"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Unknown Python implementation `foobar` + "###); +} + +#[test] +fn toolchain_find_invalid_version() { + let context = TestContext::new("3.12"); + + // No interpreters on the path + uv_snapshot!(context.filters(), context.toolchain_find().arg("--version").arg("foobar"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: invalid digit found in string + "###); +}