From 154f3fe78a0a043cf27002e7c35288942fca7a41 Mon Sep 17 00:00:00 2001 From: j178 <10510431+j178@users.noreply.github.com> Date: Wed, 4 Dec 2024 19:24:51 +0800 Subject: [PATCH] Add `uv tool list --outdated` to show outdated tools --- crates/uv-cli/src/lib.rs | 4 + crates/uv/src/commands/pip/latest.rs | 2 +- crates/uv/src/commands/tool/list.rs | 246 ++++++++++++++++++++++++++- crates/uv/src/lib.rs | 6 + crates/uv/src/settings.rs | 3 + crates/uv/tests/it/tool_list.rs | 79 +++++++++ docs/reference/cli.md | 2 + 7 files changed, 334 insertions(+), 8 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 4127f22f57aa..59b6cb8961c6 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -3857,6 +3857,10 @@ pub struct ToolListArgs { #[arg(long)] pub show_version_specifiers: bool, + /// List only tools that are outdated. + #[arg(long)] + pub outdated: bool, + // Hide unused global Python options. #[arg(long, hide = true)] pub python_preference: Option, diff --git a/crates/uv/src/commands/pip/latest.rs b/crates/uv/src/commands/pip/latest.rs index 1331c29f5bc1..5ae934de11c9 100644 --- a/crates/uv/src/commands/pip/latest.rs +++ b/crates/uv/src/commands/pip/latest.rs @@ -27,7 +27,7 @@ impl<'env> LatestClient<'env> { &self, package: &PackageName, index: Option<&IndexUrl>, - ) -> anyhow::Result, uv_client::Error> { + ) -> Result, uv_client::Error> { debug!("Fetching latest version of: `{package}`"); let archives = match self.client.simple(package, index, self.capabilities).await { diff --git a/crates/uv/src/commands/tool/list.rs b/crates/uv/src/commands/tool/list.rs index 35e5860af239..153611029b65 100644 --- a/crates/uv/src/commands/tool/list.rs +++ b/crates/uv/src/commands/tool/list.rs @@ -1,21 +1,47 @@ use std::fmt::Write; use anyhow::Result; +use futures::StreamExt; use itertools::Itertools; use owo_colors::OwoColorize; +use rustc_hash::FxHashMap; +use tracing::warn; -use uv_cache::Cache; +use uv_cache::{Cache, Refresh}; +use uv_cache_info::Timestamp; +use uv_client::{Connectivity, RegistryClientBuilder}; +use uv_configuration::{Concurrency, TrustedHost}; +use uv_distribution_filename::DistFilename; +use uv_distribution_types::IndexCapabilities; use uv_fs::Simplified; -use uv_tool::InstalledTools; +use uv_normalize::PackageName; +use uv_pep440::Version; +use uv_python::{ + EnvironmentPreference, PythonEnvironment, PythonInstallation, PythonPreference, PythonRequest, + PythonVariant, VersionRequest, +}; +use uv_resolver::RequiresPython; +use uv_settings::ResolverInstallerOptions; +use uv_tool::{InstalledTools, Tool}; use uv_warnings::warn_user; +use crate::commands::pip::latest::LatestClient; +use crate::commands::reporters::LatestVersionReporter; use crate::commands::ExitStatus; use crate::printer::Printer; +use crate::settings::ResolverInstallerSettings; /// List installed tools. +#[allow(clippy::fn_params_excessive_bools)] pub(crate) async fn list( show_paths: bool, show_version_specifiers: bool, + outdated: bool, + python_preference: PythonPreference, + connectivity: Connectivity, + concurrency: Concurrency, + native_tls: bool, + allow_insecure_host: &[TrustedHost], cache: &Cache, printer: Printer, ) -> Result { @@ -37,6 +63,91 @@ pub(crate) async fn list( return Ok(ExitStatus::Success); } + // Get the versions of the installed tools. + let versions = tools + .iter() + .map(|(name, _)| { + let version = installed_tools.version(name, cache); + (name.clone(), version) + }) + .collect::>(); + + let latest = if outdated { + let reporter = LatestVersionReporter::from(printer).with_length(tools.len() as u64); + + // Filter out malformed tools. + let tools = tools + .iter() + .filter_map(|(name, tool)| { + if let Ok(ref tool) = tool { + if versions[name].is_ok() { + return Some((name, tool)); + } + }; + None + }) + .collect_vec(); + + // Fetch the latest version for each tool. + let mut fetches = futures::stream::iter(tools) + .map(|(name, tool)| { + // SAFETY: The tool is known to be well-formed, get_environment will not fail. + let environment = installed_tools + .get_environment(name, cache) + .unwrap() + .unwrap(); + async move { + let latest = find_latest( + name, + tool, + &environment, + python_preference, + connectivity, + native_tls, + allow_insecure_host, + cache, + ) + .await?; + anyhow::Ok((name, latest)) + } + }) + .buffer_unordered(concurrency.downloads); + + let mut map = FxHashMap::default(); + while let Some((package, version)) = fetches.next().await.transpose()? { + if let Some(version) = version.as_ref() { + reporter.on_fetch_version(package, version.version()); + map.insert(package.clone(), version.clone()); + } else { + reporter.on_fetch_progress(); + } + } + reporter.on_fetch_complete(); + map + } else { + FxHashMap::default() + }; + + // Remove tools that are up-to-date. + let tools = if outdated { + tools + .into_iter() + .filter(|(name, tool)| { + if tool.is_err() { + return true; + } + let Ok(version) = versions[name].as_ref() else { + return true; + }; + latest + .get(name) + .map_or(true, |filename| filename.version() > version) + }) + .collect_vec() + } else { + tools + }; + for (name, tool) in tools { // Skip invalid tools let Ok(tool) = tool else { @@ -48,7 +159,7 @@ pub(crate) async fn list( }; // Output tool name and version - let version = match installed_tools.version(&name, cache) { + let version = match &versions[&name] { Ok(version) => version, Err(e) => { writeln!(printer.stderr(), "{e}")?; @@ -73,18 +184,35 @@ pub(crate) async fn list( String::new() }; + let latest = if outdated { + let latest = latest.get(&name).map(DistFilename::version); + if let Some(latest) = latest { + format!(" (latest: v{latest})") + } else { + String::new() + } + } else { + String::new() + }; + if show_paths { writeln!( printer.stdout(), - "{} ({})", + "{}{}{}", format!("{name} v{version}{version_specifier}").bold(), - installed_tools.tool_dir(&name).simplified_display().cyan(), + latest.bold().cyan(), + format!( + " ({})", + installed_tools.tool_dir(&name).simplified_display() + ) + .cyan(), )?; } else { writeln!( printer.stdout(), - "{}", - format!("{name} v{version}{version_specifier}").bold() + "{}{}", + format!("{name} v{version}{version_specifier}").bold(), + latest.bold().cyan(), )?; } @@ -105,3 +233,107 @@ pub(crate) async fn list( Ok(ExitStatus::Success) } + +/// Find the latest version of a tool. +async fn find_latest( + name: &PackageName, + tool: &Tool, + environment: &PythonEnvironment, + python_preference: PythonPreference, + connectivity: Connectivity, + native_tls: bool, + allow_insecure_host: &[TrustedHost], + cache: &Cache, +) -> Result> { + let capabilities = IndexCapabilities::default(); + + let ResolverInstallerSettings { + index_locations, + index_strategy, + keyring_provider, + prerelease, + exclude_newer, + .. + } = ResolverInstallerOptions::from(tool.options().clone()).into(); + + // Initialize the registry client. + let client = + RegistryClientBuilder::new(cache.clone().with_refresh(Refresh::All(Timestamp::now()))) + .native_tls(native_tls) + .connectivity(connectivity) + .index_urls(index_locations.index_urls()) + .index_strategy(index_strategy) + .keyring(keyring_provider) + .allow_insecure_host(allow_insecure_host.to_vec()) + .markers(environment.interpreter().markers()) + .platform(environment.interpreter().platform()) + .build(); + + // Determine the platform tags. + let interpreter = environment.interpreter(); + let tags = interpreter.tags()?; + + // Determine the `requires-python` specifier. + let python_request = tool.python().as_deref().map(PythonRequest::parse); + let requires_python = if let Some(python_request) = python_request { + match python_request { + PythonRequest::Version(VersionRequest::MajorMinor( + major, + minor, + PythonVariant::Default, + )) => RequiresPython::greater_than_equal_version(&Version::new([ + u64::from(major), + u64::from(minor), + ])), + PythonRequest::Version(VersionRequest::MajorMinorPatch( + major, + minor, + patch, + PythonVariant::Default, + )) => RequiresPython::greater_than_equal_version(&Version::new([ + u64::from(major), + u64::from(minor), + u64::from(patch), + ])), + PythonRequest::Version(VersionRequest::Range(ref specifiers, _)) => { + RequiresPython::from_specifiers(specifiers) + } + python_request => { + match PythonInstallation::find( + &python_request, + EnvironmentPreference::OnlySystem, + python_preference, + cache, + ) { + Ok(installation) => { + let interpreter = installation.into_interpreter(); + RequiresPython::greater_than_equal_version( + &interpreter.python_minor_version(), + ) + } + Err(err) => { + warn!( + "Failed to find a Python interpreter for tool `{name}` by request `{python_request}`, use current tool interpreter instead: {err}", + ); + RequiresPython::greater_than_equal_version( + &interpreter.python_minor_version(), + ) + } + } + } + } + } else { + RequiresPython::greater_than_equal_version(&interpreter.python_minor_version()) + }; + + let client = LatestClient { + client: &client, + capabilities: &capabilities, + prerelease, + exclude_newer, + tags: Some(tags), + requires_python: &requires_python, + }; + + Ok(client.find_latest(name, None).await?) +} diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 20216bc18ee2..4768ae1d0428 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1007,6 +1007,12 @@ async fn run(mut cli: Cli) -> Result { commands::tool_list( args.show_paths, args.show_version_specifiers, + args.outdated, + globals.python_preference, + globals.connectivity, + globals.concurrency, + globals.native_tls, + &globals.allow_insecure_host, &cache, printer, ) diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index b2ddf1376f65..28743a14ed2b 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -643,6 +643,7 @@ impl ToolUpgradeSettings { pub(crate) struct ToolListSettings { pub(crate) show_paths: bool, pub(crate) show_version_specifiers: bool, + pub(crate) outdated: bool, } impl ToolListSettings { @@ -652,6 +653,7 @@ impl ToolListSettings { let ToolListArgs { show_paths, show_version_specifiers, + outdated, python_preference: _, no_python_downloads: _, } = args; @@ -659,6 +661,7 @@ impl ToolListSettings { Self { show_paths, show_version_specifiers, + outdated, } } } diff --git a/crates/uv/tests/it/tool_list.rs b/crates/uv/tests/it/tool_list.rs index bf3dd9255020..04d0568f47b7 100644 --- a/crates/uv/tests/it/tool_list.rs +++ b/crates/uv/tests/it/tool_list.rs @@ -305,3 +305,82 @@ fn tool_list_show_version_specifiers() { ----- stderr ----- "#); } + +#[test] +fn tool_list_outdated() { + let context = TestContext::new("3.12").with_filtered_exe_suffix(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + // Install an outdated version of `black`. + context + .tool_install() + .arg("black<24.3.0") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) + .assert() + .success(); + // Install the latest version of `flask`. + context + .tool_install() + .arg("flask") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) + .assert() + .success(); + + uv_snapshot!(context.filters(), context.tool_list().arg("--outdated") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r#" + success: true + exit_code: 0 + ----- stdout ----- + black v24.2.0 (latest: v24.3.0) + - black + - blackd + + ----- stderr ----- + "#); + + // with version specifiers + uv_snapshot!(context.filters(), context.tool_list().arg("--outdated").arg("--show-version-specifiers") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r#" + success: true + exit_code: 0 + ----- stdout ----- + black v24.2.0 [required: <24.3.0] (latest: v24.3.0) + - black + - blackd + + ----- stderr ----- + "#); + + // with paths + uv_snapshot!(context.filters(), context.tool_list().arg("--outdated").arg("--show-paths") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r#" + success: true + exit_code: 0 + ----- stdout ----- + black v24.2.0 (latest: v24.3.0) ([TEMP_DIR]/tools/black) + - black ([TEMP_DIR]/bin/black) + - blackd ([TEMP_DIR]/bin/blackd) + + ----- stderr ----- + "#); + + // with specifiers and paths + uv_snapshot!(context.filters(), context.tool_list().arg("--outdated").arg("--show-version-specifiers").arg("--show-paths") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r#" + success: true + exit_code: 0 + ----- stdout ----- + black v24.2.0 [required: <24.3.0] (latest: v24.3.0) ([TEMP_DIR]/tools/black) + - black ([TEMP_DIR]/bin/black) + - blackd ([TEMP_DIR]/bin/blackd) + + ----- stderr ----- + "#); +} diff --git a/docs/reference/cli.md b/docs/reference/cli.md index f1bde17bd36f..47ff8b94a072 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -3856,6 +3856,8 @@ uv tool list [OPTIONS]

When disabled, uv will only use locally cached data and locally available files.

+
--outdated

List only tools that are outdated

+
--project project

Run the command within the given project directory.

All pyproject.toml, uv.toml, and .python-version files will be discovered by walking up the directory tree from the project root, as will the project’s virtual environment (.venv).