diff --git a/crates/uv-toolchain/src/discovery.rs b/crates/uv-toolchain/src/discovery.rs index 92de7ce4a..f4a9974ee 100644 --- a/crates/uv-toolchain/src/discovery.rs +++ b/crates/uv-toolchain/src/discovery.rs @@ -1126,7 +1126,7 @@ impl VersionRequest { } } - fn matches_version(self, version: &PythonVersion) -> bool { + pub(crate) fn matches_version(self, version: &PythonVersion) -> bool { match self { Self::Any => true, Self::Major(major) => version.major() == major, diff --git a/crates/uv-toolchain/src/managed.rs b/crates/uv-toolchain/src/managed.rs index 73f927f9e..36d36fe2e 100644 --- a/crates/uv-toolchain/src/managed.rs +++ b/crates/uv-toolchain/src/managed.rs @@ -6,14 +6,16 @@ use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::str::FromStr; use thiserror::Error; +use tracing::warn; use uv_state::{StateBucket, StateStore}; use crate::downloads::Error as DownloadError; -use crate::implementation::Error as ImplementationError; +use crate::implementation::{Error as ImplementationError, ImplementationName}; use crate::platform::Error as PlatformError; use crate::platform::{Arch, Libc, Os}; use crate::python_version::PythonVersion; +use crate::ToolchainRequest; use uv_fs::Simplified; #[derive(Error, Debug)] @@ -42,8 +44,10 @@ pub enum Error { #[source] err: io::Error, }, - #[error("Failed to parse toolchain directory name: {0}")] + #[error("Failed to read toolchain directory name: {0}")] NameError(String), + #[error("Failed to parse toolchain directory name `{0}`: {1}")] + NameParseError(String, String), } /// A collection of uv-managed Python toolchains installed on the current system. #[derive(Debug, Clone)] @@ -137,7 +141,13 @@ impl InstalledToolchains { }; Ok(dirs .into_iter() - .map(|path| InstalledToolchain::new(path).unwrap()) + .filter_map(|path| { + InstalledToolchain::new(path) + .inspect_err(|err| { + warn!("Ignoring malformed toolchain entry:\n {err}"); + }) + .ok() + }) .rev()) } @@ -193,7 +203,9 @@ pub struct InstalledToolchain { path: PathBuf, /// The Python version of the toolchain. python_version: PythonVersion, - /// An install key for the toolchain + /// The name of the Python implementation of the toolchain. + implementation: ImplementationName, + /// An install key for the toolchain. key: String, } @@ -205,14 +217,27 @@ impl InstalledToolchain { .to_str() .ok_or(Error::NameError("not a valid string".to_string()))? .to_string(); - let python_version = PythonVersion::from_str(key.split('-').nth(1).ok_or( - Error::NameError("not enough `-`-separated values".to_string()), - )?) - .map_err(|err| Error::NameError(format!("invalid Python version: {err}")))?; + + let parts = key.split('-').collect::>(); + let [implementation, version, ..] = parts.as_slice() else { + return Err(Error::NameParseError( + key.clone(), + "not enough `-`-separated values".to_string(), + )); + }; + + let implementation = ImplementationName::from_str(implementation).map_err(|err| { + Error::NameParseError(key.clone(), format!("invalid Python implementation: {err}")) + })?; + + let python_version = PythonVersion::from_str(version).map_err(|err| { + Error::NameParseError(key.clone(), format!("invalid Python version: {err}")) + })?; Ok(Self { path, python_version, + implementation, key, }) } @@ -238,6 +263,26 @@ impl InstalledToolchain { pub fn key(&self) -> &str { &self.key } + + pub fn satisfies(&self, request: &ToolchainRequest) -> bool { + match request { + ToolchainRequest::File(path) => self.executable() == *path, + ToolchainRequest::Any => true, + ToolchainRequest::Directory(path) => self.path() == *path, + ToolchainRequest::ExecutableName(name) => self + .executable() + .file_name() + .map_or(false, |filename| filename.to_string_lossy() == *name), + ToolchainRequest::Implementation(implementation) => { + *implementation == self.implementation + } + ToolchainRequest::ImplementationVersion(implementation, version) => { + *implementation == self.implementation + && version.matches_version(&self.python_version) + } + ToolchainRequest::Version(version) => version.matches_version(&self.python_version), + } + } } /// Generate a platform portion of a key from the environment. diff --git a/crates/uv/src/cli.rs b/crates/uv/src/cli.rs index 004ce1dbd..c8e080672 100644 --- a/crates/uv/src/cli.rs +++ b/crates/uv/src/cli.rs @@ -1988,6 +1988,9 @@ pub(crate) struct ToolchainNamespace { pub(crate) enum ToolchainCommand { /// List the available toolchains. List(ToolchainListArgs), + + /// Download and install a specific toolchain. + Install(ToolchainInstallArgs), } #[derive(Args)] @@ -2002,6 +2005,15 @@ pub(crate) struct ToolchainListArgs { pub(crate) only_installed: bool, } +#[derive(Args)] +#[allow(clippy::struct_excessive_bools)] +pub(crate) struct ToolchainInstallArgs { + /// The toolchain to fetch. + /// + /// If not provided, the latest available version will be installed. + pub(crate) target: 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 2b88ac5db..f83605e32 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::install::install as toolchain_install; pub(crate) use toolchain::list::list as toolchain_list; use uv_cache::Cache; use uv_fs::Simplified; diff --git a/crates/uv/src/commands/toolchain/install.rs b/crates/uv/src/commands/toolchain/install.rs new file mode 100644 index 000000000..383f711d5 --- /dev/null +++ b/crates/uv/src/commands/toolchain/install.rs @@ -0,0 +1,99 @@ +use anyhow::Result; +use std::fmt::Write; +use uv_cache::Cache; +use uv_client::Connectivity; +use uv_configuration::PreviewMode; +use uv_fs::Simplified; +use uv_toolchain::downloads::{DownloadResult, PythonDownload, PythonDownloadRequest}; +use uv_toolchain::managed::InstalledToolchains; +use uv_toolchain::ToolchainRequest; +use uv_warnings::warn_user; + +use crate::commands::ExitStatus; +use crate::printer::Printer; + +/// Download and install a Python toolchain. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn install( + target: Option, + native_tls: bool, + connectivity: Connectivity, + preview: PreviewMode, + _cache: &Cache, + printer: Printer, +) -> Result { + if preview.is_disabled() { + warn_user!("`uv toolchain fetch` is experimental and may change without warning."); + } + + let toolchains = InstalledToolchains::from_settings()?.init()?; + let toolchain_dir = toolchains.root(); + + let request = if let Some(target) = target { + let request = ToolchainRequest::parse(&target); + match request { + ToolchainRequest::Any => (), + ToolchainRequest::Directory(_) + | ToolchainRequest::ExecutableName(_) + | ToolchainRequest::File(_) => { + writeln!(printer.stderr(), "Invalid toolchain request '{target}'")?; + return Ok(ExitStatus::Failure); + } + _ => { + writeln!(printer.stderr(), "Looking for {request}")?; + } + } + request + } else { + writeln!(printer.stderr(), "Using latest Python version")?; + ToolchainRequest::default() + }; + + if let Some(toolchain) = toolchains + .find_all()? + .find(|toolchain| toolchain.satisfies(&request)) + { + writeln!( + printer.stderr(), + "Found installed toolchain '{}'", + toolchain.key() + )?; + writeln!( + printer.stderr(), + "Already installed at {}", + toolchain.path().user_display() + )?; + return Ok(ExitStatus::Success); + } + + // Fill platform information missing from the request + let request = PythonDownloadRequest::from_request(request)?.fill()?; + + // Find the corresponding download + let download = PythonDownload::from_request(&request)?; + let version = download.python_version(); + + // Construct a client + let client = uv_client::BaseClientBuilder::new() + .connectivity(connectivity) + .native_tls(native_tls) + .build(); + + writeln!(printer.stderr(), "Downloading {}", download.key())?; + let result = download.fetch(&client, toolchain_dir).await?; + + let path = match result { + // Note we should only encounter `AlreadyAvailable` if there's a race condition + // TODO(zanieb): We should lock the toolchain directory on fetch + DownloadResult::AlreadyAvailable(path) => path, + DownloadResult::Fetched(path) => path, + }; + + writeln!( + printer.stderr(), + "Installed Python {version} to {}", + path.user_display() + )?; + + Ok(ExitStatus::Success) +} diff --git a/crates/uv/src/commands/toolchain/mod.rs b/crates/uv/src/commands/toolchain/mod.rs index c5ea1738d..ef5a37748 100644 --- a/crates/uv/src/commands/toolchain/mod.rs +++ b/crates/uv/src/commands/toolchain/mod.rs @@ -1 +1,2 @@ +pub(crate) mod install; pub(crate) mod list; diff --git a/crates/uv/src/main.rs b/crates/uv/src/main.rs index 54d8c1262..c91340168 100644 --- a/crates/uv/src/main.rs +++ b/crates/uv/src/main.rs @@ -684,6 +684,25 @@ async fn run() -> Result { commands::toolchain_list(args.includes, globals.preview, &cache, printer).await } + Commands::Toolchain(ToolchainNamespace { + command: ToolchainCommand::Install(args), + }) => { + // Resolve the settings from the command-line arguments and workspace configuration. + let args = settings::ToolchainInstallSettings::resolve(args, workspace); + + // Initialize the cache. + let cache = cache.init()?; + + commands::toolchain_install( + args.target, + globals.native_tls, + globals.connectivity, + globals.preview, + &cache, + printer, + ) + .await + } } } diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 362b741b0..b625e8520 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, ToolchainListArgs, VenvArgs, + ToolRunArgs, ToolchainInstallArgs, ToolchainListArgs, VenvArgs, }; use crate::commands::ListFormat; @@ -266,6 +266,23 @@ impl ToolchainListSettings { } } +/// The resolved settings to use for a `toolchain fetch` invocation. +#[allow(clippy::struct_excessive_bools)] +#[derive(Debug, Clone)] +pub(crate) struct ToolchainInstallSettings { + pub(crate) target: Option, +} + +impl ToolchainInstallSettings { + /// Resolve the [`ToolchainInstallSettings`] from the CLI and workspace configuration. + #[allow(clippy::needless_pass_by_value)] + pub(crate) fn resolve(args: ToolchainInstallArgs, _workspace: Option) -> Self { + let ToolchainInstallArgs { target } = args; + + Self { target } + } +} + /// The resolved settings to use for a `sync` invocation. #[allow(clippy::struct_excessive_bools, dead_code)] #[derive(Debug, Clone)]