mirror of
https://github.com/astral-sh/uv.git
synced 2025-07-07 13:25:00 +00:00
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:
parent
857b3cc777
commit
cb580d1a5d
3 changed files with 144 additions and 8 deletions
|
@ -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,
|
||||
|
||||
|
|
|
@ -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)))
|
||||
}
|
||||
|
|
|
@ -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
|
||||
"###);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue