Add uv tool list (#4630)

What it says on the tin.

We skip tools with malformed receipts now and warn instead of failing
all tool operations.
This commit is contained in:
Zanie Blue 2024-06-28 18:00:18 -04:00 committed by GitHub
parent 948c0f151b
commit a444e59668
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 168 additions and 3 deletions

1
Cargo.lock generated
View file

@ -5031,6 +5031,7 @@ dependencies = [
"uv-state",
"uv-toolchain",
"uv-virtualenv",
"uv-warnings",
]
[[package]]

View file

@ -1873,6 +1873,8 @@ pub enum ToolCommand {
Run(ToolRunArgs),
/// Install a tool
Install(ToolInstallArgs),
/// List installed tools.
List(ToolListArgs),
}
#[derive(Args)]
@ -1969,6 +1971,10 @@ pub struct ToolInstallArgs {
pub python: Option<String>,
}
#[derive(Args)]
#[allow(clippy::struct_excessive_bools)]
pub struct ToolListArgs;
#[derive(Args)]
#[allow(clippy::struct_excessive_bools)]
pub struct ToolchainNamespace {

View file

@ -21,6 +21,7 @@ uv-virtualenv = { workspace = true }
uv-toolchain = { workspace = true }
install-wheel-rs = { workspace = true }
pep440_rs = { workspace = true }
uv-warnings = { workspace = true }
uv-cache = { workspace = true }
thiserror = { workspace = true }

View file

@ -11,6 +11,7 @@ use tracing::debug;
use uv_cache::Cache;
use uv_fs::{LockedFile, Simplified};
use uv_toolchain::{Interpreter, PythonEnvironment};
use uv_warnings::warn_user_once;
pub use receipt::ToolReceipt;
pub use tool::Tool;
@ -80,9 +81,9 @@ impl InstalledTools {
let path = directory.join("uv-receipt.toml");
let contents = match fs_err::read_to_string(&path) {
Ok(contents) => contents,
// TODO(zanieb): Consider warning on malformed tools instead
Err(err) if err.kind() == io::ErrorKind::NotFound => {
return Err(Error::MissingToolReceipt(name.clone(), path.clone()))
warn_user_once!("Ignoring malformed tool `{name}`: missing receipt");
continue;
}
Err(err) => return Err(err.into()),
};

View file

@ -25,6 +25,7 @@ pub(crate) use project::sync::sync;
#[cfg(feature = "self-update")]
pub(crate) use self_update::self_update;
pub(crate) use tool::install::install as tool_install;
pub(crate) use tool::list::list as tool_list;
pub(crate) use tool::run::run as tool_run;
pub(crate) use toolchain::find::find as toolchain_find;
pub(crate) use toolchain::install::install as toolchain_install;

View file

@ -0,0 +1,35 @@
use std::fmt::Write;
use anyhow::Result;
use uv_configuration::PreviewMode;
use uv_tool::InstalledTools;
use uv_warnings::warn_user_once;
use crate::commands::ExitStatus;
use crate::printer::Printer;
/// List installed tools.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn list(preview: PreviewMode, printer: Printer) -> Result<ExitStatus> {
if preview.is_disabled() {
warn_user_once!("`uv tool list` is experimental and may change without warning.");
}
let installed_tools = InstalledTools::from_settings()?;
let mut tools = installed_tools.tools()?.into_iter().collect::<Vec<_>>();
tools.sort_by_key(|(name, _)| name.clone());
if tools.is_empty() {
writeln!(printer.stderr(), "No tools installed")?;
return Ok(ExitStatus::Success);
}
// TODO(zanieb): Track and display additional metadata, like entry points
for (name, _tool) in tools {
writeln!(printer.stdout(), "{name}")?;
}
Ok(ExitStatus::Success)
}

View file

@ -1,2 +1,3 @@
pub(crate) mod install;
pub(crate) mod list;
pub(crate) mod run;

View file

@ -830,6 +830,16 @@ async fn run() -> Result<ExitStatus> {
)
.await
}
Commands::Tool(ToolNamespace {
command: ToolCommand::List(args),
}) => {
// Resolve the settings from the command-line arguments and workspace configuration.
let args = settings::ToolListSettings::resolve(args, filesystem);
show_settings!(args);
commands::tool_list(globals.preview, printer).await
}
Commands::Toolchain(ToolchainNamespace {
command: ToolchainCommand::List(args),
}) => {

View file

@ -14,7 +14,8 @@ use uv_cli::{
AddArgs, ColorChoice, Commands, ExternalCommand, GlobalArgs, ListFormat, LockArgs, Maybe,
PipCheckArgs, PipCompileArgs, PipFreezeArgs, PipInstallArgs, PipListArgs, PipShowArgs,
PipSyncArgs, PipTreeArgs, PipUninstallArgs, RemoveArgs, RunArgs, SyncArgs, ToolInstallArgs,
ToolRunArgs, ToolchainFindArgs, ToolchainInstallArgs, ToolchainListArgs, VenvArgs,
ToolListArgs, ToolRunArgs, ToolchainFindArgs, ToolchainInstallArgs, ToolchainListArgs,
VenvArgs,
};
use uv_client::Connectivity;
use uv_configuration::{
@ -275,6 +276,21 @@ impl ToolInstallSettings {
}
}
/// The resolved settings to use for a `tool list` invocation.
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone)]
pub(crate) struct ToolListSettings;
impl ToolListSettings {
/// Resolve the [`ToolListSettings`] from the CLI and filesystem configuration.
#[allow(clippy::needless_pass_by_value)]
pub(crate) fn resolve(args: ToolListArgs, _filesystem: Option<FilesystemOptions>) -> Self {
let ToolListArgs {} = args;
Self {}
}
}
#[derive(Debug, Clone, Default)]
pub(crate) enum ToolchainListKinds {
#[default]

View file

@ -412,6 +412,14 @@ impl TestContext {
command
}
/// Create a `uv tool list` command with options shared across scenarios.
pub fn tool_list(&self) -> std::process::Command {
let mut command = std::process::Command::new(get_bin());
command.arg("tool").arg("list");
self.add_shared_args(&mut command);
command
}
/// Create a `uv add` command for the given requirements.
pub fn add(&self, reqs: &[&str]) -> Command {
let mut command = Command::new(get_bin());

View file

@ -0,0 +1,85 @@
#![cfg(all(feature = "python", feature = "pypi"))]
use assert_cmd::assert::OutputAssertExt;
use assert_fs::fixture::PathChild;
use common::{uv_snapshot, TestContext};
mod common;
#[test]
fn tool_list() {
let context = TestContext::new("3.12");
let tool_dir = context.temp_dir.child("tools");
let bin_dir = context.temp_dir.child("bin");
// Install `black`
context
.tool_install()
.arg("black==24.2.0")
.env("UV_TOOL_DIR", tool_dir.as_os_str())
.env("XDG_BIN_HOME", bin_dir.as_os_str())
.assert()
.success();
uv_snapshot!(context.filters(), context.tool_list()
.env("UV_TOOL_DIR", tool_dir.as_os_str())
.env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###"
success: true
exit_code: 0
----- stdout -----
black
----- stderr -----
warning: `uv tool list` is experimental and may change without warning.
"###);
}
#[test]
fn tool_list_empty() {
let context = TestContext::new("3.12");
let tool_dir = context.temp_dir.child("tools");
let bin_dir = context.temp_dir.child("bin");
uv_snapshot!(context.filters(), context.tool_list()
.env("UV_TOOL_DIR", tool_dir.as_os_str())
.env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: `uv tool list` is experimental and may change without warning.
No tools installed
"###);
}
#[test]
fn tool_list_missing_receipt() {
let context = TestContext::new("3.12");
let tool_dir = context.temp_dir.child("tools");
let bin_dir = context.temp_dir.child("bin");
// Install `black`
context
.tool_install()
.arg("black==24.2.0")
.env("UV_TOOL_DIR", tool_dir.as_os_str())
.env("XDG_BIN_HOME", bin_dir.as_os_str())
.assert()
.success();
fs_err::remove_file(tool_dir.join("black").join("uv-receipt.toml")).unwrap();
uv_snapshot!(context.filters(), context.tool_list()
.env("UV_TOOL_DIR", tool_dir.as_os_str())
.env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
warning: `uv tool list` is experimental and may change without warning.
warning: Ignoring malformed tool `black`: missing receipt
No tools installed
"###);
}