From cb580d1a5d04525ef066ee0f8e90b74b2ce852c5 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Thu, 27 Jun 2024 06:50:15 -0400 Subject: [PATCH] Add support for specifying `name@version` in `uv tool run` (#4572) Instead of requiring `uv tool run --from package==version command` we support `uv tool run command@version` shorthand. --- crates/uv-cli/src/lib.rs | 7 +++ crates/uv/src/commands/tool/run.rs | 47 +++++++++++--- crates/uv/tests/tool_run.rs | 98 ++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 8 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index d57533fe7..0496d55f0 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -1861,6 +1861,13 @@ pub enum ToolCommand { #[allow(clippy::struct_excessive_bools)] pub struct ToolRunArgs { /// The command to run. + /// + /// By default, the package to install is assumed to match the command name. + /// + /// The name of the command can include an exact version in the format `@`. + /// + /// If more complex version specification is desired or if the command is provided by a different + /// package, use `--from`. #[command(subcommand)] pub command: ExternalCommand, diff --git a/crates/uv/src/commands/tool/run.rs b/crates/uv/src/commands/tool/run.rs index e1ed923b7..59d066bee 100644 --- a/crates/uv/src/commands/tool/run.rs +++ b/crates/uv/src/commands/tool/run.rs @@ -1,7 +1,11 @@ +use std::borrow::Cow; +use std::ffi::OsString; use std::path::PathBuf; +use std::str::FromStr; use anyhow::{Context, Result}; use itertools::Itertools; +use pep440_rs::Version; use tokio::process::Command; use tracing::debug; @@ -46,16 +50,13 @@ pub(crate) async fn run( return Err(anyhow::anyhow!("No tool command provided")); }; - let from = if let Some(from) = from { - from + let (target, from) = if let Some(from) = from { + (Cow::Borrowed(target), Cow::Owned(from)) } else { - let Some(target) = target.to_str() else { - return Err(anyhow::anyhow!("Tool command could not be parsed as UTF-8 string. Use `--from` to specify the package name.")); - }; - target.to_string() + parse_target(target)? }; - let requirements = [RequirementsSource::from_package(from)] + let requirements = [RequirementsSource::from_package(from.to_string())] .into_iter() .chain(with.into_iter().map(RequirementsSource::from_package)) .collect::>(); @@ -109,7 +110,7 @@ pub(crate) async fn run( let command = target; // Construct the command - let mut process = Command::new(command); + let mut process = Command::new(command.as_ref()); process.args(args); // Construct the `PATH` environment variable. @@ -167,3 +168,33 @@ pub(crate) async fn run( Ok(ExitStatus::Failure) } } + +/// Parse a target into a command name and a requirement. +fn parse_target(target: &OsString) -> Result<(Cow, Cow)> { + let Some(target_str) = target.to_str() else { + return Err(anyhow::anyhow!("Tool command could not be parsed as UTF-8 string. Use `--from` to specify the package name.")); + }; + + // e.g. `uv`, no special handling + let Some((name, version)) = target_str.split_once('@') else { + return Ok((Cow::Borrowed(target), Cow::Borrowed(target_str))); + }; + + // e.g. `uv@`, warn and treat the whole thing as the command + if version.is_empty() { + debug!("Ignoring empty version request in command"); + return Ok((Cow::Borrowed(target), Cow::Borrowed(target_str))); + } + + // e.g. `uv@0.1.0`, convert to `uv==0.1.0` + if let Ok(version) = Version::from_str(version) { + return Ok(( + Cow::Owned(OsString::from(name)), + Cow::Owned(format!("{name}=={version}")), + )); + } + + // e.g. `uv@invalid`, warn and treat the whole thing as the command + debug!("Ignoring invalid version request `{}` in command", version); + Ok((Cow::Borrowed(target), Cow::Borrowed(target_str))) +} diff --git a/crates/uv/tests/tool_run.rs b/crates/uv/tests/tool_run.rs index 03e91fd4e..68439d59c 100644 --- a/crates/uv/tests/tool_run.rs +++ b/crates/uv/tests/tool_run.rs @@ -53,3 +53,101 @@ fn tool_run_args() { + pytest==8.1.1 "###); } + +#[test] +fn tool_run_at_version() { + let context = TestContext::new("3.12"); + + uv_snapshot!(context.filters(), context.tool_run().arg("pytest@8.0.0").arg("--version"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + pytest 8.0.0 + + ----- stderr ----- + warning: `uv tool run` is experimental and may change without warning. + Resolved 4 packages in [TIME] + Prepared 4 packages in [TIME] + Installed 4 packages in [TIME] + + iniconfig==2.0.0 + + packaging==24.0 + + pluggy==1.4.0 + + pytest==8.0.0 + "###); + + // Empty versions are just treated as package and command names + uv_snapshot!(context.filters(), context.tool_run().arg("pytest@").arg("--version"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: `uv tool run` is experimental and may change without warning. + error: Failed to parse: `pytest@` + Caused by: Expected URL + pytest@ + ^ + "###); + + // Invalid versions are just treated as package and command names + uv_snapshot!(context.filters(), context.tool_run().arg("pytest@invalid").arg("--version"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: `uv tool run` is experimental and may change without warning. + error: Distribution not found at: file://[TEMP_DIR]/invalid + "###); + + let filters = context + .filters() + .into_iter() + .chain([( + // The error message is different on Windows + "Caused by: program not found", + "Caused by: No such file or directory (os error 2)", + )]) + .collect::>(); + + // When `--from` is used, `@` is not treated as a version request + uv_snapshot!(filters, context.tool_run().arg("--from").arg("pytest").arg("pytest@8.0.0").arg("--version"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: `uv tool run` is experimental and may change without warning. + Resolved 4 packages in [TIME] + Prepared 1 package in [TIME] + Installed 4 packages in [TIME] + + iniconfig==2.0.0 + + packaging==24.0 + + pluggy==1.4.0 + + pytest==8.1.1 + error: Failed to spawn: `pytest@8.0.0` + Caused by: No such file or directory (os error 2) + "###); +} + +#[test] +fn tool_run_from_version() { + let context = TestContext::new("3.12"); + + uv_snapshot!(context.filters(), context.tool_run().arg("--from").arg("pytest==8.0.0").arg("pytest").arg("--version"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + pytest 8.0.0 + + ----- stderr ----- + warning: `uv tool run` is experimental and may change without warning. + Resolved 4 packages in [TIME] + Prepared 4 packages in [TIME] + Installed 4 packages in [TIME] + + iniconfig==2.0.0 + + packaging==24.0 + + pluggy==1.4.0 + + pytest==8.0.0 + "###); +}