diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index 0f4b8c024..ace541728 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -117,7 +117,7 @@ pub(crate) async fn install( .unwrap() } // Ex) `ruff@0.6.0` - Target::Version(name, ref version) => { + Target::Version(name, ref version) | Target::FromVersion(_, name, ref version) => { if editable { bail!("`--editable` is only supported for local packages"); } @@ -136,7 +136,7 @@ pub(crate) async fn install( } } // Ex) `ruff@latest` - Target::Latest(name) => { + Target::Latest(name) | Target::FromLatest(_, name) => { if editable { bail!("`--editable` is only supported for local packages"); } @@ -153,7 +153,7 @@ pub(crate) async fn install( } } // Ex) `ruff>=0.6.0` - Target::UserDefined(package, from) => { + Target::From(package, from) => { // Parse the positional name. If the user provided more than a package name, it's an error // (e.g., `uv install foo==1.0 --from foo`). let Ok(package) = PackageName::from_str(package) else { diff --git a/crates/uv/src/commands/tool/mod.rs b/crates/uv/src/commands/tool/mod.rs index b01a4c65e..b725b0ae3 100644 --- a/crates/uv/src/commands/tool/mod.rs +++ b/crates/uv/src/commands/tool/mod.rs @@ -22,15 +22,49 @@ pub(crate) enum Target<'a> { Version(&'a str, Version), /// e.g., `ruff@latest` Latest(&'a str), - /// e.g., `--from ruff==0.6.0` - UserDefined(&'a str, &'a str), + /// e.g., `ruff --from ruff>=0.6.0` + From(&'a str, &'a str), + /// e.g., `ruff --from ruff@0.6.0` + FromVersion(&'a str, &'a str, Version), + /// e.g., `ruff --from ruff@latest` + FromLatest(&'a str, &'a str), } impl<'a> Target<'a> { /// Parse a target into a command name and a requirement. pub(crate) fn parse(target: &'a str, from: Option<&'a str>) -> Self { if let Some(from) = from { - return Self::UserDefined(target, from); + // e.g. `--from ruff`, no special handling + let Some((name, version)) = from.split_once('@') else { + return Self::From(target, from); + }; + + // e.g. `--from ruff@`, warn and treat the whole thing as the command + if version.is_empty() { + debug!("Ignoring empty version request in `--from`"); + return Self::From(target, from); + } + + // e.g., ignore `git+https://github.com/astral-sh/ruff.git@main` + if PackageName::from_str(name).is_err() { + debug!("Ignoring non-package name `{name}` in `--from`"); + return Self::From(target, from); + } + + match version { + // e.g., `ruff@latest` + "latest" => return Self::FromLatest(target, name), + // e.g., `ruff@0.6.0` + version => { + if let Ok(version) = Version::from_str(version) { + return Self::FromVersion(target, name, version); + } + } + }; + + // e.g. `--from ruff@invalid`, warn and treat the whole thing as the command + debug!("Ignoring invalid version request `{version}` in `--from`"); + return Self::From(target, from); } // e.g. `ruff`, no special handling @@ -72,12 +106,14 @@ impl<'a> Target<'a> { Self::Unspecified(name) => name, Self::Version(name, _) => name, Self::Latest(name) => name, - Self::UserDefined(name, _) => name, + Self::FromVersion(name, _, _) => name, + Self::FromLatest(name, _) => name, + Self::From(name, _) => name, } } /// Returns `true` if the target is `latest`. fn is_latest(&self) -> bool { - matches!(self, Self::Latest(_)) + matches!(self, Self::Latest(_) | Self::FromLatest(_, _)) } } diff --git a/crates/uv/src/commands/tool/run.rs b/crates/uv/src/commands/tool/run.rs index 6a7f27522..4500f0412 100644 --- a/crates/uv/src/commands/tool/run.rs +++ b/crates/uv/src/commands/tool/run.rs @@ -352,7 +352,7 @@ async fn get_or_create_environment( origin: None, }, // Ex) `ruff@0.6.0` - Target::Version(name, version) => Requirement { + Target::Version(name, version) | Target::FromVersion(_, name, version) => Requirement { name: PackageName::from_str(name)?, extras: vec![], marker: MarkerTree::default(), @@ -365,7 +365,7 @@ async fn get_or_create_environment( origin: None, }, // Ex) `ruff@latest` - Target::Latest(name) => Requirement { + Target::Latest(name) | Target::FromLatest(_, name) => Requirement { name: PackageName::from_str(name)?, extras: vec![], marker: MarkerTree::default(), @@ -376,7 +376,7 @@ async fn get_or_create_environment( origin: None, }, // Ex) `ruff>=0.6.0` - Target::UserDefined(_, from) => resolve_names( + Target::From(_, from) => resolve_names( vec![RequirementsSpecification::parse_package(from)?], &interpreter, settings, diff --git a/crates/uv/tests/tool_install.rs b/crates/uv/tests/tool_install.rs index 03c2330ec..f350c1df1 100644 --- a/crates/uv/tests/tool_install.rs +++ b/crates/uv/tests/tool_install.rs @@ -2657,6 +2657,94 @@ fn tool_install_at_latest() { }); } +/// Test installing a tool with `uv tool install {package} --from {package}@latest`. +#[test] +fn tool_install_from_at_latest() { + let context = TestContext::new("3.12") + .with_filtered_counts() + .with_filtered_exe_suffix(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + uv_snapshot!(context.filters(), context.tool_install() + .arg("pybabel") + .arg("--from") + .arg("babel@latest") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + babel==2.14.0 + Installed 1 executable: pybabel + "###); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!(fs_err::read_to_string(tool_dir.join("babel").join("uv-receipt.toml")).unwrap(), @r###" + [tool] + requirements = [{ name = "babel" }] + entrypoints = [ + { name = "pybabel", install-path = "[TEMP_DIR]/bin/pybabel" }, + ] + + [tool.options] + exclude-newer = "2024-03-25T00:00:00Z" + "###); + }); +} + +/// Test installing a tool with `uv tool install {package} --from {package}@{version}`. +#[test] +fn tool_install_from_at_version() { + let context = TestContext::new("3.12") + .with_filtered_counts() + .with_filtered_exe_suffix(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + uv_snapshot!(context.filters(), context.tool_install() + .arg("pybabel") + .arg("--from") + .arg("babel@2.13.0") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()) + .env("PATH", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + babel==2.13.0 + Installed 1 executable: pybabel + "###); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!(fs_err::read_to_string(tool_dir.join("babel").join("uv-receipt.toml")).unwrap(), @r###" + [tool] + requirements = [{ name = "babel", specifier = "==2.13.0" }] + entrypoints = [ + { name = "pybabel", install-path = "[TEMP_DIR]/bin/pybabel" }, + ] + + [tool.options] + exclude-newer = "2024-03-25T00:00:00Z" + "###); + }); +} + /// Test upgrading an already installed tool via `{package}@{latest}`. #[test] fn tool_install_at_latest_upgrade() {