diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 0496d55f0..204f7b9df 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -1908,13 +1908,13 @@ pub struct ToolRunArgs { #[derive(Args)] #[allow(clippy::struct_excessive_bools)] pub struct ToolInstallArgs { - /// The command to install. - pub name: String, + /// The package to install commands from. + pub package: String, - /// Use the given package to provide the command. + /// The package to install commands from. /// - /// By default, the package name is assumed to match the command name. - #[arg(long)] + /// This option is provided for parity with `uv tool run`, but is redundant with `package`. + #[arg(long, hide = true)] pub from: Option, /// Include the following extra requirements. diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index 8b6354724..d7d67293f 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -30,9 +30,9 @@ use crate::settings::ResolverInstallerSettings; /// Install a tool. #[allow(clippy::too_many_arguments)] pub(crate) async fn install( - name: String, - python: Option, + package: String, from: Option, + python: Option, with: Vec, force: bool, settings: ResolverInstallerSettings, @@ -47,12 +47,35 @@ pub(crate) async fn install( if preview.is_disabled() { warn_user_once!("`uv tool install` is experimental and may change without warning."); } - let from = from.unwrap_or(name.clone()); + + let from = if let Some(from) = from { + let from_requirement = Requirement::::from_str(&from)?; + // Check if the user provided more than just a name positionally or if that name conflicts with `--from` + if from_requirement.name.to_string() != package { + // Determine if its an entirely different package or a conflicting specification + let package_requirement = Requirement::::from_str(&package)?; + if from_requirement.name == package_requirement.name { + bail!( + "Package requirement `{}` provided with `--from` conflicts with install request `{}`", + from, + package + ); + } + bail!( + "Package name `{}` provided with `--from` does not match install request `{}`", + from_requirement.name, + package + ); + } + from_requirement + } else { + Requirement::::from_str(&package)? + }; + + let name = from.name.to_string(); let installed_tools = InstalledTools::from_settings()?; - // TODO(zanieb): Figure out the interface here, do we infer the name or do we match the `run --from` interface? - let from = Requirement::::from_str(&from)?; let existing_tool_receipt = installed_tools.get_tool_receipt(&name)?; // TODO(zanieb): Automatically replace an existing tool if the request differs let reinstall_entry_points = if existing_tool_receipt.is_some() { @@ -219,7 +242,7 @@ pub(crate) async fn install( installed_tools.add_tool_receipt(&name, tool)?; writeln!( - printer.stdout(), + printer.stderr(), "Installed: {}", targets.iter().map(|(name, _, _)| name).join(", ") )?; diff --git a/crates/uv/src/main.rs b/crates/uv/src/main.rs index d7442634b..feb33268a 100644 --- a/crates/uv/src/main.rs +++ b/crates/uv/src/main.rs @@ -814,9 +814,9 @@ async fn run() -> Result { let cache = cache.init()?.with_refresh(args.refresh); commands::tool_install( - args.name, - args.python, + args.package, args.from, + args.python, args.with, args.force, args.settings, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 3dcb3cda5..5d175d709 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -235,7 +235,7 @@ impl ToolRunSettings { #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone)] pub(crate) struct ToolInstallSettings { - pub(crate) name: String, + pub(crate) package: String, pub(crate) from: Option, pub(crate) with: Vec, pub(crate) python: Option, @@ -249,7 +249,7 @@ impl ToolInstallSettings { #[allow(clippy::needless_pass_by_value)] pub(crate) fn resolve(args: ToolInstallArgs, filesystem: Option) -> Self { let ToolInstallArgs { - name, + package, from, with, installer, @@ -260,7 +260,7 @@ impl ToolInstallSettings { } = args; Self { - name, + package, from, with, python, diff --git a/crates/uv/tests/tool_install.rs b/crates/uv/tests/tool_install.rs index 9e32db9b1..18c2e945e 100644 --- a/crates/uv/tests/tool_install.rs +++ b/crates/uv/tests/tool_install.rs @@ -27,7 +27,6 @@ fn tool_install() { success: true exit_code: 0 ----- stdout ----- - Installed: black, blackd ----- stderr ----- warning: `uv tool install` is experimental and may change without warning. @@ -40,6 +39,7 @@ fn tool_install() { + packaging==24.0 + pathspec==0.12.1 + platformdirs==4.2.0 + Installed: black, blackd "###); tool_dir.child("black").assert(predicate::path::is_dir()); @@ -98,7 +98,6 @@ fn tool_install() { success: true exit_code: 0 ----- stdout ----- - Installed: flask ----- stderr ----- warning: `uv tool install` is experimental and may change without warning. @@ -112,6 +111,7 @@ fn tool_install() { + jinja2==3.1.3 + markupsafe==2.1.5 + werkzeug==3.0.1 + Installed: flask "###); tool_dir.child("flask").assert(predicate::path::is_dir()); @@ -158,6 +158,150 @@ fn tool_install() { }); } +/// Test installing a tool at a version +#[test] +fn tool_install_version() { + let context = TestContext::new("3.12"); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + // Install `black` + uv_snapshot!(context.filters(), 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()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv tool install` is experimental and may change without warning. + Resolved 6 packages in [TIME] + Prepared 6 packages in [TIME] + Installed 6 packages in [TIME] + + black==24.2.0 + + click==8.1.7 + + mypy-extensions==1.0.0 + + packaging==24.0 + + pathspec==0.12.1 + + platformdirs==4.2.0 + Installed: black, blackd + "###); + + tool_dir.child("black").assert(predicate::path::is_dir()); + tool_dir + .child("black") + .child("uv-receipt.toml") + .assert(predicate::path::exists()); + + let executable = bin_dir.child(format!("black{}", std::env::consts::EXE_SUFFIX)); + assert!(executable.exists()); + + // On Windows, we can't snapshot an executable file. + #[cfg(not(windows))] + insta::with_settings!({ + filters => context.filters(), + }, { + // Should run black in the virtual environment + assert_snapshot!(fs_err::read_to_string(executable).unwrap(), @r###" + #![TEMP_DIR]/tools/black/bin/python + # -*- coding: utf-8 -*- + import re + import sys + from black import patched_main + if __name__ == "__main__": + sys.argv[0] = re.sub(r"(-script\.pyw|\.exe)?$", "", sys.argv[0]) + sys.exit(patched_main()) + "###); + + }); + + insta::with_settings!({ + filters => context.filters(), + }, { + // We should have a tool receipt + assert_snapshot!(fs_err::read_to_string(tool_dir.join("black").join("uv-receipt.toml")).unwrap(), @r###" + [tool] + requirements = ["black==24.2.0"] + "###); + }); + + uv_snapshot!(context.filters(), Command::new("black").arg("--version").env("PATH", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + black, 24.2.0 (compiled: yes) + Python (CPython) 3.12.[X] + + ----- stderr ----- + "###); +} + +/// Test installing a tool with `uv tool install --from` +#[test] +fn tool_install_from() { + let context = TestContext::new("3.12"); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + // Install `black` using `--from` to specify the version + uv_snapshot!(context.filters(), context.tool_install() + .arg("black") + .arg("--from") + .arg("black==24.2.0") + .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 install` is experimental and may change without warning. + Resolved 6 packages in [TIME] + Prepared 6 packages in [TIME] + Installed 6 packages in [TIME] + + black==24.2.0 + + click==8.1.7 + + mypy-extensions==1.0.0 + + packaging==24.0 + + pathspec==0.12.1 + + platformdirs==4.2.0 + Installed: black, blackd + "###); + + // Attempt to install `black` using `--from` with a different package name + uv_snapshot!(context.filters(), context.tool_install() + .arg("black") + .arg("--from") + .arg("flask==24.2.0") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: `uv tool install` is experimental and may change without warning. + error: Package name `flask` provided with `--from` does not match install request `black` + "###); + + // Attempt to install `black` using `--from` with a different version + uv_snapshot!(context.filters(), context.tool_install() + .arg("black==24.2.0") + .arg("--from") + .arg("black==24.3.0") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: `uv tool install` is experimental and may change without warning. + error: Package requirement `black==24.3.0` provided with `--from` conflicts with install request `black==24.2.0` + "###); +} + /// Test installing and reinstalling an already installed tool #[test] fn tool_install_already_installed() { @@ -173,7 +317,6 @@ fn tool_install_already_installed() { success: true exit_code: 0 ----- stdout ----- - Installed: black, blackd ----- stderr ----- warning: `uv tool install` is experimental and may change without warning. @@ -186,6 +329,7 @@ fn tool_install_already_installed() { + packaging==24.0 + pathspec==0.12.1 + platformdirs==4.2.0 + Installed: black, blackd "###); tool_dir.child("black").assert(predicate::path::is_dir()); @@ -265,7 +409,6 @@ fn tool_install_already_installed() { success: true exit_code: 0 ----- stdout ----- - Installed: black, blackd ----- stderr ----- warning: `uv tool install` is experimental and may change without warning. @@ -277,6 +420,7 @@ fn tool_install_already_installed() { + packaging==24.0 + pathspec==0.12.1 + platformdirs==4.2.0 + Installed: black, blackd "###); // Install `black` again with `--reinstall-package` for `black` @@ -290,7 +434,6 @@ fn tool_install_already_installed() { success: true exit_code: 0 ----- stdout ----- - Installed: black, blackd ----- stderr ----- warning: `uv tool install` is experimental and may change without warning. @@ -299,6 +442,7 @@ fn tool_install_already_installed() { Installed [N] packages in [TIME] - black==24.3.0 + black==24.3.0 + Installed: black, blackd "###); // Install `black` again with `--reinstall-package` for a dependency @@ -446,7 +590,6 @@ fn tool_install_entry_point_exists() { success: true exit_code: 0 ----- stdout ----- - Installed: black, blackd ----- stderr ----- warning: `uv tool install` is experimental and may change without warning. @@ -458,6 +601,7 @@ fn tool_install_entry_point_exists() { + packaging==24.0 + pathspec==0.12.1 + platformdirs==4.2.0 + Installed: black, blackd "###); tool_dir.child("black").assert(predicate::path::is_dir()); @@ -526,7 +670,6 @@ fn tool_install_home() { success: true exit_code: 0 ----- stdout ----- - Installed: black, blackd ----- stderr ----- warning: `uv tool install` is experimental and may change without warning. @@ -539,6 +682,7 @@ fn tool_install_home() { + packaging==24.0 + pathspec==0.12.1 + platformdirs==4.2.0 + Installed: black, blackd "###); context @@ -562,7 +706,6 @@ fn tool_install_xdg_data_home() { success: true exit_code: 0 ----- stdout ----- - Installed: black, blackd ----- stderr ----- warning: `uv tool install` is experimental and may change without warning. @@ -575,6 +718,7 @@ fn tool_install_xdg_data_home() { + packaging==24.0 + pathspec==0.12.1 + platformdirs==4.2.0 + Installed: black, blackd "###); context @@ -598,7 +742,6 @@ fn tool_install_xdg_bin_home() { success: true exit_code: 0 ----- stdout ----- - Installed: black, blackd ----- stderr ----- warning: `uv tool install` is experimental and may change without warning. @@ -611,6 +754,7 @@ fn tool_install_xdg_bin_home() { + packaging==24.0 + pathspec==0.12.1 + platformdirs==4.2.0 + Installed: black, blackd "###); bin_dir