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.
This commit is contained in:
Zanie Blue 2024-06-27 06:50:15 -04:00 committed by GitHub
parent 857b3cc777
commit cb580d1a5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 144 additions and 8 deletions

View file

@ -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 `<package>@<version>`.
///
/// If more complex version specification is desired or if the command is provided by a different
/// package, use `--from`.
#[command(subcommand)]
pub command: ExternalCommand,

View file

@ -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::<Vec<_>>();
@ -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<OsString>, Cow<str>)> {
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)))
}

View file

@ -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::<Vec<_>>();
// 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
"###);
}