diff --git a/crates/uv-toolchain/src/downloads.rs b/crates/uv-toolchain/src/downloads.rs index d10d07872..da9bc2f9d 100644 --- a/crates/uv-toolchain/src/downloads.rs +++ b/crates/uv-toolchain/src/downloads.rs @@ -128,6 +128,7 @@ impl PythonDownloadRequest { self } + /// Construct a new [`PythonDownloadRequest`] from a [`ToolchainRequest`]. pub fn from_request(request: ToolchainRequest) -> Result { let result = Self::default(); let result = match request { @@ -149,6 +150,9 @@ impl PythonDownloadRequest { Ok(result) } + /// Fill empty entries with default values. + /// + /// Platform information is pulled from the environment. pub fn fill(mut self) -> Result { if self.implementation.is_none() { self.implementation = Some(ImplementationName::CPython); @@ -164,6 +168,48 @@ impl PythonDownloadRequest { } Ok(self) } + + /// Construct a new [`PythonDownloadRequest`] with platform information from the environment. + pub fn from_env() -> Result { + Ok(Self::new( + None, + None, + Some(Arch::from_env()?), + Some(Os::from_env()?), + Some(Libc::from_env()), + )) + } + + /// Iterate over all [`PythonDownload`]'s that match this request. + pub fn iter_downloads(&self) -> impl Iterator + '_ { + PythonDownload::iter_all().filter(move |download| { + if let Some(arch) = &self.arch { + if download.arch != *arch { + return false; + } + } + if let Some(os) = &self.os { + if download.os != *os { + return false; + } + } + if let Some(implementation) = &self.implementation { + if download.implementation != *implementation { + return false; + } + } + if let Some(version) = &self.version { + if !version.matches_major_minor_patch( + download.major, + download.minor, + download.patch, + ) { + return false; + } + } + true + }) + } } impl Display for PythonDownloadRequest { @@ -211,41 +257,27 @@ impl PythonDownload { PYTHON_DOWNLOADS.iter().find(|&value| value.key == key) } + /// Return the first [`PythonDownload`] matching a request, if any. pub fn from_request(request: &PythonDownloadRequest) -> Result<&'static PythonDownload, Error> { - for download in PYTHON_DOWNLOADS { - if let Some(arch) = &request.arch { - if download.arch != *arch { - continue; - } - } - if let Some(os) = &request.os { - if download.os != *os { - continue; - } - } - if let Some(implementation) = &request.implementation { - if download.implementation != *implementation { - continue; - } - } - if let Some(version) = &request.version { - if !version.matches_major_minor_patch( - download.major, - download.minor, - download.patch, - ) { - continue; - } - } - return Ok(download); - } - Err(Error::NoDownloadFound(request.clone())) + request + .iter_downloads() + .next() + .ok_or(Error::NoDownloadFound(request.clone())) + } + + /// Iterate over all [`PythonDownload`]'s. + pub fn iter_all() -> impl Iterator { + PYTHON_DOWNLOADS.iter() } pub fn url(&self) -> &str { self.url } + pub fn key(&self) -> &str { + self.key + } + pub fn sha256(&self) -> Option<&str> { self.sha256 } diff --git a/crates/uv-toolchain/src/managed.rs b/crates/uv-toolchain/src/managed.rs index db8d6e9fe..73f927f9e 100644 --- a/crates/uv-toolchain/src/managed.rs +++ b/crates/uv-toolchain/src/managed.rs @@ -108,7 +108,7 @@ impl InstalledToolchains { /// ordering across platforms. This also results in newer Python versions coming first, /// but should not be relied on — instead the toolchains should be sorted later by /// the parsed Python version. - fn find_all(&self) -> Result, Error> { + pub fn find_all(&self) -> Result, Error> { let dirs = match fs_err::read_dir(&self.root) { Ok(toolchain_dirs) => { // Collect sorted directory paths; `read_dir` is not stable across platforms @@ -191,27 +191,29 @@ impl InstalledToolchains { pub struct InstalledToolchain { /// The path to the top-level directory of the installed toolchain. path: PathBuf, + /// The Python version of the toolchain. python_version: PythonVersion, + /// An install key for the toolchain + key: String, } impl InstalledToolchain { pub fn new(path: PathBuf) -> Result { - let python_version = PythonVersion::from_str( - path.file_name() - .ok_or(Error::NameError("name is empty".to_string()))? - .to_str() - .ok_or(Error::NameError("not a valid string".to_string()))? - .split('-') - .nth(1) - .ok_or(Error::NameError( - "not enough `-`-separated values".to_string(), - ))?, - ) + let key = path + .file_name() + .ok_or(Error::NameError("name is empty".to_string()))? + .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}")))?; Ok(Self { path, python_version, + key, }) } @@ -228,6 +230,14 @@ impl InstalledToolchain { pub fn python_version(&self) -> &PythonVersion { &self.python_version } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn key(&self) -> &str { + &self.key + } } /// 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 a2a7cdc91..004ce1dbd 100644 --- a/crates/uv/src/cli.rs +++ b/crates/uv/src/cli.rs @@ -130,6 +130,8 @@ pub(crate) enum Commands { Pip(PipNamespace), /// Run and manage executable Python packages. Tool(ToolNamespace), + /// Manage Python installations. + Toolchain(ToolchainNamespace), /// Create a virtual environment. #[command(alias = "virtualenv", alias = "v")] Venv(VenvArgs), @@ -1976,6 +1978,30 @@ pub(crate) struct ToolRunArgs { pub(crate) python: Option, } +#[derive(Args)] +pub(crate) struct ToolchainNamespace { + #[command(subcommand)] + pub(crate) command: ToolchainCommand, +} + +#[derive(Subcommand)] +pub(crate) enum ToolchainCommand { + /// List the available toolchains. + List(ToolchainListArgs), +} + +#[derive(Args)] +#[allow(clippy::struct_excessive_bools)] +pub(crate) struct ToolchainListArgs { + /// List all available toolchains, including those that do not match the current platform. + #[arg(long, conflicts_with = "only_installed")] + pub(crate) all: bool, + + /// Only list installed toolchains. + #[arg(long, conflicts_with = "all")] + pub(crate) only_installed: bool, +} + #[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 d042e88ce..2b88ac5db 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::list::list as toolchain_list; use uv_cache::Cache; use uv_fs::Simplified; use uv_installer::compile_tree; @@ -39,6 +40,7 @@ mod pip; mod project; pub(crate) mod reporters; mod tool; +mod toolchain; #[cfg(feature = "self-update")] mod self_update; diff --git a/crates/uv/src/commands/toolchain/list.rs b/crates/uv/src/commands/toolchain/list.rs new file mode 100644 index 000000000..711c5523f --- /dev/null +++ b/crates/uv/src/commands/toolchain/list.rs @@ -0,0 +1,70 @@ +use std::fmt::Write; +use std::ops::Deref; + +use anyhow::Result; +use itertools::Itertools; + +use uv_cache::Cache; +use uv_configuration::PreviewMode; +use uv_toolchain::downloads::PythonDownloadRequest; +use uv_toolchain::managed::InstalledToolchains; +use uv_warnings::warn_user; + +use crate::commands::ExitStatus; +use crate::printer::Printer; +use crate::settings::ToolchainListIncludes; + +/// List available toolchains. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn list( + includes: ToolchainListIncludes, + preview: PreviewMode, + _cache: &Cache, + printer: Printer, +) -> Result { + if preview.is_disabled() { + warn_user!("`uv toolchain list` is experimental and may change without warning."); + } + + let downloads = match includes { + ToolchainListIncludes::All => { + let request = PythonDownloadRequest::default(); + request.iter_downloads().collect() + } + ToolchainListIncludes::Installed => Vec::new(), + ToolchainListIncludes::Default => { + let request = PythonDownloadRequest::from_env()?; + request.iter_downloads().collect() + } + }; + + let installed = { + InstalledToolchains::from_settings()? + .init()? + .find_all()? + .collect_vec() + }; + + let mut output = Vec::new(); + for toolchain in installed { + output.push(( + toolchain.python_version().deref().version.clone(), + toolchain.key().to_owned(), + )); + } + for download in downloads { + output.push(( + download.python_version().deref().version.clone(), + download.key().to_owned(), + )); + } + + output.sort(); + output.dedup(); + + for (version, key) in output { + writeln!(printer.stdout(), "{:<8} ({key})", version.to_string())?; + } + + Ok(ExitStatus::Success) +} diff --git a/crates/uv/src/commands/toolchain/mod.rs b/crates/uv/src/commands/toolchain/mod.rs new file mode 100644 index 000000000..c5ea1738d --- /dev/null +++ b/crates/uv/src/commands/toolchain/mod.rs @@ -0,0 +1 @@ +pub(crate) mod list; diff --git a/crates/uv/src/main.rs b/crates/uv/src/main.rs index 7df58182b..aa7cf0329 100644 --- a/crates/uv/src/main.rs +++ b/crates/uv/src/main.rs @@ -10,7 +10,7 @@ use clap::{CommandFactory, Parser}; use owo_colors::OwoColorize; use tracing::instrument; -use cli::{ToolCommand, ToolNamespace}; +use cli::{ToolCommand, ToolNamespace, ToolchainCommand, ToolchainNamespace}; use uv_cache::Cache; use uv_requirements::RequirementsSource; use uv_workspace::Combine; @@ -671,6 +671,17 @@ async fn run() -> Result { ) .await } + Commands::Toolchain(ToolchainNamespace { + command: ToolchainCommand::List(args), + }) => { + // Resolve the settings from the command-line arguments and workspace configuration. + let args = settings::ToolchainListSettings::resolve(args, workspace); + + // Initialize the cache. + let cache = cache.init()?; + + commands::toolchain_list(args.includes, globals.preview, &cache, printer).await + } } } diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index bb109b415..362b741b0 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, VenvArgs, + ToolRunArgs, ToolchainListArgs, VenvArgs, }; use crate::commands::ListFormat; @@ -230,6 +230,42 @@ impl ToolRunSettings { } } +#[derive(Debug, Clone, Default)] +pub(crate) enum ToolchainListIncludes { + #[default] + Default, + All, + Installed, +} + +/// The resolved settings to use for a `tool run` invocation. +#[allow(clippy::struct_excessive_bools)] +#[derive(Debug, Clone)] +pub(crate) struct ToolchainListSettings { + pub(crate) includes: ToolchainListIncludes, +} + +impl ToolchainListSettings { + /// Resolve the [`ToolchainListSettings`] from the CLI and workspace configuration. + #[allow(clippy::needless_pass_by_value)] + pub(crate) fn resolve(args: ToolchainListArgs, _workspace: Option) -> Self { + let ToolchainListArgs { + all, + only_installed, + } = args; + + let includes = if all { + ToolchainListIncludes::All + } else if only_installed { + ToolchainListIncludes::Installed + } else { + ToolchainListIncludes::default() + }; + + Self { includes } + } +} + /// The resolved settings to use for a `sync` invocation. #[allow(clippy::struct_excessive_bools, dead_code)] #[derive(Debug, Clone)]