diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index ef46cf7b3b14..35b8c8e74d50 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -3156,6 +3156,10 @@ pub struct ToolInstallArgs { help_heading = "Python options" )] pub python: Option, + + /// The suffix to install the package and executables with + #[arg(long, help_heading = "Installer options")] + pub suffix: Option, } #[derive(Args)] diff --git a/crates/uv-tool/src/lib.rs b/crates/uv-tool/src/lib.rs index 153eeac4bf26..75934bcc3f02 100644 --- a/crates/uv-tool/src/lib.rs +++ b/crates/uv-tool/src/lib.rs @@ -56,6 +56,23 @@ pub enum Error { Serialization(#[from] toml_edit::ser::Error), } +/// A Tool name +/// +/// TODO: This representation works for installing, but could be problematic for upgrading and uninstalling +/// Consider changing it to either a single String (with the resolved name), or an enum with variants for different identifiers +#[derive(Debug, Clone)] +pub struct ToolName { + pub name: PackageName, + pub suffix: Option, +} + +impl fmt::Display for ToolName { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + // TODO Decide how Tools are displayed + write!(f, "{}", self.name) + } +} + /// A collection of uv-managed tools installed on the current system. #[derive(Debug, Clone)] pub struct InstalledTools { @@ -87,8 +104,12 @@ impl InstalledTools { } /// Return the expected directory for a tool with the given [`PackageName`]. - pub fn tool_dir(&self, name: &PackageName) -> PathBuf { - self.root.join(name.to_string()) + pub fn tool_dir(&self, name: &ToolName) -> PathBuf { + if let Some(suffix) = &name.suffix { + self.root.join(name.name.to_string() + suffix) + } else { + self.root.join(name.name.to_string()) + } } /// Return the metadata for all installed tools. @@ -130,7 +151,7 @@ impl InstalledTools { /// error. /// /// Note it is generally incorrect to use this without [`Self::acquire_lock`]. - pub fn get_tool_receipt(&self, name: &PackageName) -> Result, Error> { + pub fn get_tool_receipt(&self, name: &ToolName) -> Result, Error> { let path = self.tool_dir(name).join("uv-receipt.toml"); match ToolReceipt::from_path(&path) { Ok(tool_receipt) => Ok(Some(tool_receipt.tool)), @@ -149,7 +170,7 @@ impl InstalledTools { /// Any existing receipt will be replaced. /// /// Note it is generally incorrect to use this without [`Self::acquire_lock`]. - pub fn add_tool_receipt(&self, name: &PackageName, tool: Tool) -> Result<(), Error> { + pub fn add_tool_receipt(&self, name: &ToolName, tool: Tool) -> Result<(), Error> { let tool_receipt = ToolReceipt::from(tool); let path = self.tool_dir(name).join("uv-receipt.toml"); @@ -175,7 +196,7 @@ impl InstalledTools { /// # Errors /// /// If no such environment exists for the tool. - pub fn remove_environment(&self, name: &PackageName) -> Result<(), Error> { + pub fn remove_environment(&self, name: &ToolName) -> Result<(), Error> { let environment_path = self.tool_dir(name); debug!( @@ -196,7 +217,7 @@ impl InstalledTools { /// Note it is generally incorrect to use this without [`Self::acquire_lock`]. pub fn get_environment( &self, - name: &PackageName, + name: &ToolName, cache: &Cache, ) -> Result, Error> { let environment_path = self.tool_dir(name); @@ -228,7 +249,7 @@ impl InstalledTools { /// Note it is generally incorrect to use this without [`Self::acquire_lock`]. pub fn create_environment( &self, - name: &PackageName, + name: &ToolName, interpreter: Interpreter, ) -> Result { let environment_path = self.tool_dir(name); @@ -271,15 +292,15 @@ impl InstalledTools { } /// Return the [`Version`] of an installed tool. - pub fn version(&self, name: &PackageName, cache: &Cache) -> Result { + pub fn version(&self, name: &ToolName, cache: &Cache) -> Result { let environment_path = self.tool_dir(name); let environment = PythonEnvironment::from_root(&environment_path, cache)?; let site_packages = SitePackages::from_environment(&environment) .map_err(|err| Error::EnvironmentRead(environment_path.clone(), err.to_string()))?; - let packages = site_packages.get_packages(name); + let packages = site_packages.get_packages(&name.name); let package = packages .first() - .ok_or_else(|| Error::MissingToolPackage(name.clone()))?; + .ok_or_else(|| Error::MissingToolPackage(name.name.clone()))?; Ok(package.version().clone()) } diff --git a/crates/uv/src/commands/tool/common.rs b/crates/uv/src/commands/tool/common.rs index d6b53c8e74f4..e0657346b4da 100644 --- a/crates/uv/src/commands/tool/common.rs +++ b/crates/uv/src/commands/tool/common.rs @@ -1,4 +1,5 @@ use std::fmt::Write; +use std::path::Path; use std::{collections::BTreeSet, ffi::OsString}; use anyhow::{bail, Context}; @@ -16,7 +17,9 @@ use uv_installer::SitePackages; use uv_python::PythonEnvironment; use uv_settings::ToolOptions; use uv_shell::Shell; -use uv_tool::{entrypoint_paths, find_executable_directory, InstalledTools, Tool, ToolEntrypoint}; +use uv_tool::{ + entrypoint_paths, find_executable_directory, InstalledTools, Tool, ToolEntrypoint, ToolName, +}; use uv_warnings::warn_user; use crate::commands::ExitStatus; @@ -71,6 +74,7 @@ pub(crate) fn install_executables( python: Option, requirements: Vec, printer: Printer, + suffix: &Option, ) -> anyhow::Result { let site_packages = SitePackages::from_environment(environment)?; let installed = site_packages.get_packages(name); @@ -78,6 +82,11 @@ pub(crate) fn install_executables( bail!("Expected at least one requirement") }; + let pkg = ToolName { + name: name.clone(), + suffix: suffix.clone(), + }; + // Find a suitable path to install into let executable_directory = find_executable_directory()?; fs_err::create_dir_all(&executable_directory) @@ -98,12 +107,21 @@ pub(crate) fn install_executables( let target_entry_points = entry_points .into_iter() .map(|(name, source_path)| { - let target_path = executable_directory.join( - source_path - .file_name() - .map(std::borrow::ToOwned::to_owned) - .unwrap_or_else(|| OsString::from(name.clone())), - ); + let mut file_stem = source_path + .file_stem() + .map(std::borrow::ToOwned::to_owned) + .unwrap_or_else(|| OsString::from(name.clone())); + if let Some(suffix) = suffix { + file_stem.push(suffix); + } + + let target_path = if let Some(extension) = source_path.extension() { + let path = Path::new(&file_stem).with_extension(extension); + executable_directory.join(path) + } else { + let path = Path::new(&file_stem); + executable_directory.join(path) + }; (name, source_path, target_path) }) .collect::>(); @@ -118,7 +136,7 @@ pub(crate) fn install_executables( hint_executable_from_dependency(name, &site_packages, printer)?; // Clean up the environment we just created. - installed_tools.remove_environment(name)?; + installed_tools.remove_environment(&pkg)?; return Ok(ExitStatus::Failure); } @@ -138,7 +156,7 @@ pub(crate) fn install_executables( } } else if existing_entry_points.peek().is_some() { // Clean up the environment we just created - installed_tools.remove_environment(name)?; + installed_tools.remove_environment(&pkg)?; let existing_entry_points = existing_entry_points // SAFETY: We know the target has a filename because we just constructed it above @@ -190,7 +208,7 @@ pub(crate) fn install_executables( .map(|(name, _, target_path)| ToolEntrypoint::new(name, target_path)), options, ); - installed_tools.add_tool_receipt(name, tool)?; + installed_tools.add_tool_receipt(&pkg, tool)?; // If the executable directory isn't on the user's PATH, warn. if !Shell::contains_path(&executable_directory) { diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index eef29cea3d0f..46a51c377313 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -17,7 +17,7 @@ use uv_python::{ }; use uv_requirements::{RequirementsSource, RequirementsSpecification}; use uv_settings::{ResolverInstallerOptions, ToolOptions}; -use uv_tool::InstalledTools; +use uv_tool::{InstalledTools, ToolName}; use uv_warnings::warn_user; use crate::commands::pip::loggers::{DefaultInstallLogger, DefaultResolveLogger}; @@ -42,6 +42,7 @@ pub(crate) async fn install( force: bool, options: ResolverInstallerOptions, settings: ResolverInstallerSettings, + suffix: Option, python_preference: PythonPreference, python_downloads: PythonDownloads, connectivity: Connectivity, @@ -241,6 +242,11 @@ pub(crate) async fn install( let installed_tools = InstalledTools::from_settings()?.init()?; let _lock = installed_tools.lock().await?; + let pkg = ToolName { + name: from.name.clone(), + suffix: suffix.clone(), + }; + // Find the existing receipt, if it exists. If the receipt is present but malformed, we'll // remove the environment and continue with the install. // @@ -249,31 +255,31 @@ pub(crate) async fn install( // // (If we find existing entrypoints later on, and the tool _doesn't_ exist, we'll avoid removing // the external tool's entrypoints (without `--force`).) - let (existing_tool_receipt, invalid_tool_receipt) = - match installed_tools.get_tool_receipt(&from.name) { - Ok(None) => (None, false), - Ok(Some(receipt)) => (Some(receipt), false), - Err(_) => { - // If the tool is not installed properly, remove the environment and continue. - match installed_tools.remove_environment(&from.name) { - Ok(()) => { - warn_user!( - "Removed existing `{from}` with invalid receipt", - from = from.name.cyan() - ); - } - Err(uv_tool::Error::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => {} - Err(err) => { - return Err(err.into()); - } + let (existing_tool_receipt, invalid_tool_receipt) = match installed_tools.get_tool_receipt(&pkg) + { + Ok(None) => (None, false), + Ok(Some(receipt)) => (Some(receipt), false), + Err(_) => { + // If the tool is not installed properly, remove the environment and continue. + match installed_tools.remove_environment(&pkg) { + Ok(()) => { + warn_user!( + "Removed existing `{from}` with invalid receipt", + from = from.name.cyan() + ); + } + Err(uv_tool::Error::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => { + return Err(err.into()); } - (None, true) } - }; + (None, true) + } + }; let existing_environment = installed_tools - .get_environment(&from.name, &cache)? + .get_environment(&pkg, &cache)? .filter(|environment| { python_request.as_ref().map_or(true, |python_request| { if python_request.satisfied(environment.interpreter(), &cache) { @@ -308,7 +314,7 @@ pub(crate) async fn install( if *tool_receipt.options() != options { // ...but the options differ, we need to update the receipt. installed_tools - .add_tool_receipt(&from.name, tool_receipt.clone().with_options(options))?; + .add_tool_receipt(&pkg, tool_receipt.clone().with_options(options))?; } // We're done, though we might need to update the receipt. @@ -378,7 +384,7 @@ pub(crate) async fn install( ) .await?; - let environment = installed_tools.create_environment(&from.name, interpreter)?; + let environment = installed_tools.create_environment(&pkg, interpreter)?; // At this point, we removed any existing environment, so we should remove any of its // executables. @@ -411,5 +417,6 @@ pub(crate) async fn install( python, requirements, printer, + &suffix, ) } diff --git a/crates/uv/src/commands/tool/list.rs b/crates/uv/src/commands/tool/list.rs index 8e5e0192836d..32b0b0d2d302 100644 --- a/crates/uv/src/commands/tool/list.rs +++ b/crates/uv/src/commands/tool/list.rs @@ -6,7 +6,7 @@ use owo_colors::OwoColorize; use uv_cache::Cache; use uv_fs::Simplified; -use uv_tool::InstalledTools; +use uv_tool::{InstalledTools, ToolName}; use uv_warnings::warn_user; use crate::commands::ExitStatus; @@ -46,9 +46,13 @@ pub(crate) async fn list( ); continue; }; + let pkg = ToolName { + name: name.clone(), + suffix: None, // TODO Add suffix option + }; // Output tool name and version - let version = match installed_tools.version(&name, cache) { + let version = match installed_tools.version(&pkg, cache) { Ok(version) => version, Err(e) => { writeln!(printer.stderr(), "{e}")?; @@ -74,7 +78,7 @@ pub(crate) async fn list( printer.stdout(), "{} ({})", format!("{name} v{version}{version_specifier}").bold(), - installed_tools.tool_dir(&name).simplified_display().cyan(), + installed_tools.tool_dir(&pkg).simplified_display().cyan(), )?; } else { writeln!( diff --git a/crates/uv/src/commands/tool/run.rs b/crates/uv/src/commands/tool/run.rs index cf85e93d0b4b..97307a5f6a53 100644 --- a/crates/uv/src/commands/tool/run.rs +++ b/crates/uv/src/commands/tool/run.rs @@ -25,6 +25,7 @@ use uv_python::{ PythonPreference, PythonRequest, }; use uv_requirements::{RequirementsSource, RequirementsSpecification}; +use uv_tool::ToolName; use uv_tool::{entrypoint_paths, InstalledTools}; use uv_warnings::warn_user; @@ -436,9 +437,14 @@ async fn get_or_create_environment( let installed_tools = InstalledTools::from_settings()?.init()?; let _lock = installed_tools.lock().await?; + let pkg = ToolName { + name: from.name.clone(), + suffix: None, // TODO add suffix support + }; + let existing_environment = installed_tools - .get_environment(&from.name, cache)? + .get_environment(&pkg, cache)? .filter(|environment| { python_request.as_ref().map_or(true, |python_request| { python_request.satisfied(environment.interpreter(), cache) diff --git a/crates/uv/src/commands/tool/uninstall.rs b/crates/uv/src/commands/tool/uninstall.rs index ba419898f13c..f9763a4865ef 100644 --- a/crates/uv/src/commands/tool/uninstall.rs +++ b/crates/uv/src/commands/tool/uninstall.rs @@ -7,7 +7,7 @@ use tracing::debug; use uv_fs::Simplified; use uv_normalize::PackageName; -use uv_tool::{InstalledTools, Tool, ToolEntrypoint}; +use uv_tool::{InstalledTools, Tool, ToolEntrypoint, ToolName}; use crate::commands::ExitStatus; use crate::printer::Printer; @@ -100,7 +100,12 @@ async fn do_uninstall( for (name, receipt) in installed_tools.tools()? { let Ok(receipt) = receipt else { // If the tool is not installed properly, attempt to remove the environment anyway. - match installed_tools.remove_environment(&name) { + let pkg = ToolName { + name: name.clone(), + suffix: None, // TODO add support for suffix + }; + + match installed_tools.remove_environment(&pkg) { Ok(()) => { dangling = true; writeln!( @@ -124,9 +129,14 @@ async fn do_uninstall( } else { let mut entrypoints = vec![]; for name in names { - let Some(receipt) = installed_tools.get_tool_receipt(&name)? else { + let pkg = ToolName { + name: name.clone(), + suffix: None, // TODO add support for suffix + }; + let Some(receipt) = installed_tools.get_tool_receipt(&pkg)? else { // If the tool is not installed properly, attempt to remove the environment anyway. - match installed_tools.remove_environment(&name) { + + match installed_tools.remove_environment(&pkg) { Ok(()) => { writeln!( printer.stderr(), @@ -178,7 +188,11 @@ async fn uninstall_tool( tools: &InstalledTools, ) -> Result> { // Remove the tool itself. - tools.remove_environment(name)?; + let pkg = ToolName { + name: name.clone(), + suffix: None, // TODO add support for suffix + }; + tools.remove_environment(&pkg)?; // Remove the tool's entrypoints. let entrypoints = receipt.entrypoints(); diff --git a/crates/uv/src/commands/tool/upgrade.rs b/crates/uv/src/commands/tool/upgrade.rs index d3f3e3978871..99d55ca65789 100644 --- a/crates/uv/src/commands/tool/upgrade.rs +++ b/crates/uv/src/commands/tool/upgrade.rs @@ -10,7 +10,7 @@ use uv_configuration::Concurrency; use uv_normalize::PackageName; use uv_requirements::RequirementsSpecification; use uv_settings::{Combine, ResolverInstallerOptions, ToolOptions}; -use uv_tool::InstalledTools; +use uv_tool::{InstalledTools, ToolName}; use crate::commands::pip::loggers::{SummaryResolveLogger, UpgradeInstallLogger}; use crate::commands::project::{update_environment, EnvironmentUpdate}; @@ -59,7 +59,11 @@ pub(crate) async fn upgrade( debug!("Upgrading tool: `{name}`"); // Ensure the tool is installed. - let existing_tool_receipt = match installed_tools.get_tool_receipt(&name) { + let pkg = ToolName { + name: name.clone(), + suffix: None, // TODO add support for suffix + }; + let existing_tool_receipt = match installed_tools.get_tool_receipt(&pkg) { Ok(Some(receipt)) => receipt, Ok(None) => { let install_command = format!("uv tool install {name}"); @@ -83,7 +87,11 @@ pub(crate) async fn upgrade( } }; - let existing_environment = match installed_tools.get_environment(&name, cache) { + let pkg = ToolName { + name: name.clone(), + suffix: None, // TODO add support for suffix + }; + let existing_environment = match installed_tools.get_environment(&pkg, cache) { Ok(Some(environment)) => environment, Ok(None) => { let install_command = format!("uv tool install {name}"); @@ -149,6 +157,7 @@ pub(crate) async fn upgrade( // existing executables. remove_entrypoints(&existing_tool_receipt); + let suffix = None; // TODO add support for suffix install_executables( &environment, &name, @@ -158,6 +167,7 @@ pub(crate) async fn upgrade( existing_tool_receipt.python().to_owned(), requirements.to_vec(), printer, + &suffix, /* TODO suffix upgrade */ )?; } } diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index ec211c035a47..e74356e02260 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -852,6 +852,7 @@ async fn run(cli: Cli) -> Result { args.force, args.options, args.settings, + args.suffix, globals.python_preference, globals.python_downloads, globals.connectivity, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 084373c90206..4dc43e0aad45 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -364,6 +364,7 @@ pub(crate) struct ToolInstallSettings { pub(crate) settings: ResolverInstallerSettings, pub(crate) force: bool, pub(crate) editable: bool, + pub(crate) suffix: Option, } impl ToolInstallSettings { @@ -381,6 +382,7 @@ impl ToolInstallSettings { build, refresh, python, + suffix, } = args; let options = resolver_installer_options(installer, build).combine( @@ -406,6 +408,7 @@ impl ToolInstallSettings { refresh: Refresh::from(refresh), options, settings, + suffix, } } } diff --git a/crates/uv/tests/show_settings.rs b/crates/uv/tests/show_settings.rs index 374ef8a5abf1..da5bedd510fe 100644 --- a/crates/uv/tests/show_settings.rs +++ b/crates/uv/tests/show_settings.rs @@ -2565,6 +2565,7 @@ fn resolve_tool() -> anyhow::Result<()> { }, force: false, editable: false, + suffix: None, } ----- stderr ----- diff --git a/crates/uv/tests/tool_install.rs b/crates/uv/tests/tool_install.rs index f350c1df1754..f3ff384a8089 100644 --- a/crates/uv/tests/tool_install.rs +++ b/crates/uv/tests/tool_install.rs @@ -174,6 +174,182 @@ fn tool_install() { }); } +/// Test installing a tool twice with `uv tool install --suffix` +/// and different suffixes +#[test] +fn tool_install_suffix() { + 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"); + + // Install `black` + uv_snapshot!(context.filters(), context.tool_install() + .arg("black") + .arg("--suffix") + .arg("_beta") + .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] + + black==24.3.0 + + click==8.1.7 + + mypy-extensions==1.0.0 + + packaging==24.0 + + pathspec==0.12.1 + + platformdirs==4.2.0 + Installed 2 executables: black, blackd + "###); + + tool_dir + .child("black_beta") + .assert(predicate::path::is_dir()); + tool_dir + .child("black_beta") + .child("uv-receipt.toml") + .assert(predicate::path::exists()); + + let executable = bin_dir.child(format!("black_beta{}", std::env::consts::EXE_SUFFIX)); + executable.assert(predicate::path::exists()); + 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_beta/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_beta").join("uv-receipt.toml")).unwrap(), @r###" + [tool] + requirements = [{ name = "black" }] + entrypoints = [ + { name = "black", install-path = "[TEMP_DIR]/bin/black_beta" }, + { name = "blackd", install-path = "[TEMP_DIR]/bin/blackd_beta" }, + ] + + [tool.options] + exclude-newer = "2024-03-25T00:00:00Z" + "###); + }); + + uv_snapshot!(context.filters(), Command::new("black_beta").arg("--version").env("PATH", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + black_beta, 24.3.0 (compiled: yes) + Python (CPython) 3.12.[X] + + ----- stderr ----- + "###); + + // Install `black` alfa + uv_snapshot!(context.filters(), context.tool_install() + .arg("black") + .arg("--suffix") + .arg("_alfa") + .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] + Installed [N] packages in [TIME] + + black==24.3.0 + + click==8.1.7 + + mypy-extensions==1.0.0 + + packaging==24.0 + + pathspec==0.12.1 + + platformdirs==4.2.0 + Installed 2 executables: black, blackd + "###); + + tool_dir + .child("black_alfa") + .assert(predicate::path::is_dir()); + tool_dir + .child("black_alfa") + .child("uv-receipt.toml") + .assert(predicate::path::exists()); + + let executable = bin_dir.child(format!("black_alfa{}", std::env::consts::EXE_SUFFIX)); + executable.assert(predicate::path::exists()); + 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_alfa/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_alfa").join("uv-receipt.toml")).unwrap(), @r###" + [tool] + requirements = [{ name = "black" }] + entrypoints = [ + { name = "black", install-path = "[TEMP_DIR]/bin/black_alfa" }, + { name = "blackd", install-path = "[TEMP_DIR]/bin/blackd_alfa" }, + ] + + [tool.options] + exclude-newer = "2024-03-25T00:00:00Z" + "###); + }); + + uv_snapshot!(context.filters(), Command::new("black_alfa").arg("--version").env("PATH", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + black_alfa, 24.3.0 (compiled: yes) + Python (CPython) 3.12.[X] + + ----- stderr ----- + "###); +} + #[test] fn tool_install_suggest_other_packages_with_executable() { let context = TestContext::new("3.12").with_filtered_exe_suffix(); diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 85f107e0d86b..4374e06e8841 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -2811,6 +2811,8 @@ uv tool install [OPTIONS]
  • lowest-direct: Resolve the lowest compatible version of any direct dependencies, and the highest compatible version of any transitive dependencies
  • +
    --suffix suffix

    The suffix to install the package and executables with

    +
    --upgrade, -U

    Allow package upgrades, ignoring pinned versions in any existing output file. Implies --refresh

    --upgrade-package, -P upgrade-package

    Allow upgrades for a specific package, ignoring pinned versions in any existing output file. Implies --refresh-package