diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 8f9e1efca0b2..62cdac90d0e8 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -2758,7 +2758,7 @@ pub enum ToolCommand { /// 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 - /// `@`, e.g., `uv run ruff@0.3.0`. If more complex + /// `@`, e.g., `uv tool run ruff@0.3.0`. If more complex /// version specification is desired or if the command is provided by a /// different package, use `--from`. /// diff --git a/crates/uv-configuration/src/package_options.rs b/crates/uv-configuration/src/package_options.rs index 0a58d9987312..4fd8e5dad64b 100644 --- a/crates/uv-configuration/src/package_options.rs +++ b/crates/uv-configuration/src/package_options.rs @@ -74,7 +74,7 @@ pub enum Upgrade { } impl Upgrade { - /// Determine the upgrade strategy from the command-line arguments. + /// Determine the [`Upgrade`] strategy from the command-line arguments. pub fn from_args(upgrade: Option, upgrade_package: Vec) -> Self { match upgrade { Some(true) => Self::All, @@ -97,6 +97,15 @@ impl Upgrade { } } + /// Create an [`Upgrade`] strategy to upgrade a single package. + pub fn package(package_name: PackageName) -> Self { + Self::Packages({ + let mut map = FxHashMap::default(); + map.insert(package_name, vec![]); + map + }) + } + /// Returns `true` if no packages should be upgraded. pub fn is_none(&self) -> bool { matches!(self, Self::None) @@ -130,6 +139,25 @@ impl Upgrade { Either::Left(std::iter::empty()) } } + + /// Combine a set of [`Upgrade`] values. + #[must_use] + pub fn combine(self, other: Self) -> Self { + match (self, other) { + // If both are `None`, the result is `None`. + (Self::None, Self::None) => Self::None, + // If either is `All`, the result is `All`. + (Self::All, _) | (_, Self::All) => Self::All, + // If one is `None`, the result is the other. + (Self::Packages(a), Self::None) => Self::Packages(a), + (Self::None, Self::Packages(b)) => Self::Packages(b), + // If both are `Packages`, the result is the union of the two. + (Self::Packages(mut a), Self::Packages(b)) => { + a.extend(b); + Self::Packages(a) + } + } + } } /// Create a [`Refresh`] policy by integrating the [`Upgrade`] policy. diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index e83e06c91041..4e41fc7cf478 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -4,11 +4,13 @@ use std::str::FromStr; use anyhow::{bail, Result}; use distribution_types::UnresolvedRequirementSpecification; use owo_colors::OwoColorize; +use pep440_rs::{VersionSpecifier, VersionSpecifiers}; +use pep508_rs::MarkerTree; +use pypi_types::{Requirement, RequirementSource}; use tracing::debug; - -use uv_cache::Cache; +use uv_cache::{Cache, Refresh, Timestamp}; use uv_client::{BaseClientBuilder, Connectivity}; -use uv_configuration::Concurrency; +use uv_configuration::{Concurrency, Upgrade}; use uv_normalize::PackageName; use uv_python::{ EnvironmentPreference, PythonDownloads, PythonInstallation, PythonPreference, PythonRequest, @@ -24,6 +26,7 @@ use crate::commands::project::{ resolve_environment, resolve_names, sync_environment, update_environment, }; use crate::commands::tool::common::remove_entrypoints; +use crate::commands::tool::Target; use crate::commands::{reporters::PythonDownloadReporter, tool::common::install_executables}; use crate::commands::{ExitStatus, SharedState}; use crate::printer::Printer; @@ -44,7 +47,7 @@ pub(crate) async fn install( connectivity: Connectivity, concurrency: Concurrency, native_tls: bool, - cache: &Cache, + cache: Cache, printer: Printer, ) -> Result { let client_builder = BaseClientBuilder::new() @@ -63,7 +66,7 @@ pub(crate) async fn install( python_preference, python_downloads, &client_builder, - cache, + &cache, Some(&reporter), ) .await? @@ -76,24 +79,28 @@ pub(crate) async fn install( .connectivity(connectivity) .native_tls(native_tls); - // Resolve the `from` requirement. - let from = if let Some(from) = 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 { - bail!("Package requirement (`{from}`) provided with `--from` conflicts with install request (`{package}`)", from = from.cyan(), package = package.cyan()) - }; + // Parse the input requirement. + let target = Target::parse(&package, from.as_deref()); - let source = if editable { - RequirementsSource::Editable(from) - } else { - RequirementsSource::Package(from) - }; - let requirements = RequirementsSpecification::from_source(&source, &client_builder) - .await? - .requirements; + // If the user passed, e.g., `ruff@latest`, refresh the cache. + let cache = if target.is_latest() { + cache.with_refresh(Refresh::All(Timestamp::now())) + } else { + cache + }; - let from_requirement = { + // Resolve the `--from` requirement. + let from = match target { + // Ex) `ruff` + Target::Unspecified(name) => { + let source = if editable { + RequirementsSource::Editable(name.to_string()) + } else { + RequirementsSource::Package(name.to_string()) + }; + let requirements = RequirementsSpecification::from_source(&source, &client_builder) + .await? + .requirements; resolve_names( requirements, &interpreter, @@ -102,49 +109,106 @@ pub(crate) async fn install( connectivity, concurrency, native_tls, - cache, + &cache, printer, ) .await? .pop() .unwrap() - }; + } + // Ex) `ruff@0.6.0` + Target::Version(name, ref version) => { + if editable { + bail!("`--editable` is only supported for local packages"); + } - // Check if the positional name conflicts with `--from`. - if from_requirement.name != package { - // Determine if it's an entirely different package (e.g., `uv install foo --from bar`). - bail!( - "Package name (`{}`) provided with `--from` does not match install request (`{}`)", - from_requirement.name.cyan(), - package.cyan() - ); + Requirement { + name: PackageName::from_str(name)?, + extras: vec![], + marker: MarkerTree::default(), + source: RequirementSource::Registry { + specifier: VersionSpecifiers::from(VersionSpecifier::equals_version( + version.clone(), + )), + index: None, + }, + origin: None, + } } + // Ex) `ruff@latest` + Target::Latest(name) => { + if editable { + bail!("`--editable` is only supported for local packages"); + } - from_requirement - } else { - let source = if editable { - RequirementsSource::Editable(package.clone()) - } else { - RequirementsSource::Package(package.clone()) - }; - let requirements = RequirementsSpecification::from_source(&source, &client_builder) + Requirement { + name: PackageName::from_str(name)?, + extras: vec![], + marker: MarkerTree::default(), + source: RequirementSource::Registry { + specifier: VersionSpecifiers::empty(), + index: None, + }, + origin: None, + } + } + // Ex) `ruff>=0.6.0` + Target::UserDefined(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 { + bail!("Package requirement (`{from}`) provided with `--from` conflicts with install request (`{package}`)", from = from.cyan(), package = package.cyan()) + }; + + let source = if editable { + RequirementsSource::Editable(from.to_string()) + } else { + RequirementsSource::Package(from.to_string()) + }; + let requirements = RequirementsSpecification::from_source(&source, &client_builder) + .await? + .requirements; + + // Parse the `--from` requirement. + let from_requirement = resolve_names( + requirements, + &interpreter, + &settings, + &state, + connectivity, + concurrency, + native_tls, + &cache, + printer, + ) .await? - .requirements; + .pop() + .unwrap(); + + // Check if the positional name conflicts with `--from`. + if from_requirement.name != package { + // Determine if it's an entirely different package (e.g., `uv install foo --from bar`). + bail!( + "Package name (`{}`) provided with `--from` does not match install request (`{}`)", + from_requirement.name.cyan(), + package.cyan() + ); + } - resolve_names( - requirements, - &interpreter, - &settings, - &state, - connectivity, - concurrency, - native_tls, - cache, - printer, - ) - .await? - .pop() - .unwrap() + from_requirement + } + }; + + // If the user passed, e.g., `ruff@latest`, we need to mark it as upgradable. + let settings = if target.is_latest() { + ResolverInstallerSettings { + upgrade: settings + .upgrade + .combine(Upgrade::package(from.name.clone())), + ..settings + } + } else { + settings }; // Read the `--with` requirements. @@ -163,7 +227,7 @@ pub(crate) async fn install( connectivity, concurrency, native_tls, - cache, + &cache, printer, ) .await?, @@ -209,10 +273,10 @@ pub(crate) async fn install( let existing_environment = installed_tools - .get_environment(&from.name, cache)? + .get_environment(&from.name, &cache)? .filter(|environment| { python_request.as_ref().map_or(true, |python_request| { - if python_request.satisfied(environment.interpreter(), cache) { + if python_request.satisfied(environment.interpreter(), &cache) { debug!("Found existing environment for `{from}`", from = from.name.cyan()); true } else { @@ -227,29 +291,34 @@ pub(crate) async fn install( }); // If the requested and receipt requirements are the same... - if existing_environment.is_some() { + if existing_environment + .as_ref() + .filter(|_| { + // And the user didn't request a reinstall or upgrade... + !force + && !target.is_latest() + && settings.reinstall.is_none() + && settings.upgrade.is_none() + }) + .is_some() + { if let Some(tool_receipt) = existing_tool_receipt.as_ref() { let receipt = tool_receipt.requirements().to_vec(); if requirements == receipt { - // And the user didn't request a reinstall or upgrade... - if !force && settings.reinstall.is_none() && settings.upgrade.is_none() { - 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), - )?; - } + 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))?; + } - // We're done, though we might need to update the receipt. - writeln!( - printer.stderr(), - "`{from}` is already installed", - from = from.cyan() - )?; + // We're done, though we might need to update the receipt. + writeln!( + printer.stderr(), + "`{from}` is already installed", + from = from.cyan() + )?; - return Ok(ExitStatus::Success); - } + return Ok(ExitStatus::Success); } } } @@ -279,7 +348,7 @@ pub(crate) async fn install( connectivity, concurrency, native_tls, - cache, + &cache, printer, ) .await? @@ -304,7 +373,7 @@ pub(crate) async fn install( connectivity, concurrency, native_tls, - cache, + &cache, printer, ) .await?; @@ -327,7 +396,7 @@ pub(crate) async fn install( connectivity, concurrency, native_tls, - cache, + &cache, printer, ) .await? diff --git a/crates/uv/src/commands/tool/mod.rs b/crates/uv/src/commands/tool/mod.rs index 9fc990dcc981..b01a4c65ea00 100644 --- a/crates/uv/src/commands/tool/mod.rs +++ b/crates/uv/src/commands/tool/mod.rs @@ -1,3 +1,10 @@ +use std::str::FromStr; + +use tracing::debug; + +use pep440_rs::Version; +use uv_normalize::PackageName; + mod common; pub(crate) mod dir; pub(crate) mod install; @@ -6,3 +13,71 @@ pub(crate) mod run; pub(crate) mod uninstall; pub(crate) mod update_shell; pub(crate) mod upgrade; + +#[derive(Debug, Clone)] +pub(crate) enum Target<'a> { + /// e.g., `ruff` + Unspecified(&'a str), + /// e.g., `ruff@0.6.0` + Version(&'a str, Version), + /// e.g., `ruff@latest` + Latest(&'a str), + /// e.g., `--from ruff==0.6.0` + UserDefined(&'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. `ruff`, no special handling + let Some((name, version)) = target.split_once('@') else { + return Self::Unspecified(target); + }; + + // e.g. `ruff@`, warn and treat the whole thing as the command + if version.is_empty() { + debug!("Ignoring empty version request in command"); + return Self::Unspecified(target); + } + + // 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 command"); + return Self::Unspecified(target); + } + + match version { + // e.g., `ruff@latest` + "latest" => return Self::Latest(name), + // e.g., `ruff@0.6.0` + version => { + if let Ok(version) = Version::from_str(version) { + return Self::Version(name, version); + } + } + }; + + // e.g. `ruff@invalid`, warn and treat the whole thing as the command + debug!("Ignoring invalid version request `{version}` in command"); + Self::Unspecified(target) + } + + /// Returns the name of the executable. + pub(crate) fn executable(&self) -> &str { + match self { + Self::Unspecified(name) => name, + Self::Version(name, _) => name, + Self::Latest(name) => name, + Self::UserDefined(name, _) => name, + } + } + + /// Returns `true` if the target is `latest`. + fn is_latest(&self) -> bool { + matches!(self, Self::Latest(_)) + } +} diff --git a/crates/uv/src/commands/tool/run.rs b/crates/uv/src/commands/tool/run.rs index aa7089bef84c..5f2002897335 100644 --- a/crates/uv/src/commands/tool/run.rs +++ b/crates/uv/src/commands/tool/run.rs @@ -1,4 +1,3 @@ -use std::ffi::OsString; use std::fmt::Display; use std::fmt::Write; use std::path::PathBuf; @@ -12,7 +11,7 @@ use tokio::process::Command; use tracing::{debug, warn}; use distribution_types::{Name, UnresolvedRequirementSpecification}; -use pep440_rs::{Version, VersionSpecifier, VersionSpecifiers}; +use pep440_rs::{VersionSpecifier, VersionSpecifiers}; use pep508_rs::MarkerTree; use pypi_types::{Requirement, RequirementSource}; use uv_cache::{Cache, Refresh, Timestamp}; @@ -35,6 +34,7 @@ use crate::commands::pip::loggers::{ use crate::commands::pip::operations; use crate::commands::project::{resolve_names, ProjectError}; use crate::commands::reporters::PythonDownloadReporter; +use crate::commands::tool::Target; use crate::commands::{ project::environment::CachedEnvironment, tool::common::matching_packages, tool_list, }; @@ -88,7 +88,11 @@ pub(crate) async fn run( return Err(anyhow::anyhow!("No tool command provided")); }; - let target = Target::parse(target, from.as_deref())?; + 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.")); + }; + + let target = Target::parse(target, from.as_deref()); // If the user passed, e.g., `ruff@latest`, refresh the cache. let cache = if target.is_latest() { @@ -291,78 +295,6 @@ fn warn_executable_not_provided_by_package( } } -#[derive(Debug, Clone)] -enum Target<'a> { - /// e.g., `ruff` - Unspecified(&'a str), - /// e.g., `ruff@0.6.0` - Version(&'a str, Version), - /// e.g., `ruff@latest` - Latest(&'a str), - /// e.g., `--from ruff==0.6.0` - UserDefined(&'a str, &'a str), -} - -impl<'a> Target<'a> { - /// Parse a target into a command name and a requirement. - fn parse(target: &'a OsString, from: Option<&'a str>) -> anyhow::Result { - 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.")); - }; - - if let Some(from) = from { - return Ok(Self::UserDefined(target_str, from)); - } - - // e.g. `ruff`, no special handling - let Some((name, version)) = target_str.split_once('@') else { - return Ok(Self::Unspecified(target_str)); - }; - - // e.g. `ruff@`, warn and treat the whole thing as the command - if version.is_empty() { - debug!("Ignoring empty version request in command"); - return Ok(Self::Unspecified(target_str)); - } - - // 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 command"); - return Ok(Self::Unspecified(target_str)); - } - - match version { - // e.g., `ruff@latest` - "latest" => return Ok(Self::Latest(name)), - // e.g., `ruff@0.6.0` - version => { - if let Ok(version) = Version::from_str(version) { - return Ok(Self::Version(name, version)); - } - } - }; - - // e.g. `ruff@invalid`, warn and treat the whole thing as the command - debug!("Ignoring invalid version request `{version}` in command"); - Ok(Self::Unspecified(target_str)) - } - - /// Returns the name of the executable. - fn executable(&self) -> &str { - match self { - Self::Unspecified(name) => name, - Self::Version(name, _) => name, - Self::Latest(name) => name, - Self::UserDefined(name, _) => name, - } - } - - /// Returns `true` if the target is `latest`. - fn is_latest(&self) -> bool { - matches!(self, Self::Latest(_)) - } -} - /// Get or create a [`PythonEnvironment`] in which to run the specified tools. /// /// If the target tool is already installed in a compatible environment, returns that diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 99250e561a97..9188e793eb0e 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -827,7 +827,7 @@ async fn run(cli: Cli) -> Result { globals.connectivity, globals.concurrency, globals.native_tls, - &cache, + cache, printer, ) .await diff --git a/crates/uv/tests/tool_install.rs b/crates/uv/tests/tool_install.rs index afc70360a666..03c2330ecd20 100644 --- a/crates/uv/tests/tool_install.rs +++ b/crates/uv/tests/tool_install.rs @@ -2541,3 +2541,237 @@ fn tool_install_settings() { "###); }); } + +/// Test installing a tool with `uv tool install {package}@{version}`. +#[test] +fn tool_install_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"); + + // Install `black` at `24.1.0`. + uv_snapshot!(context.filters(), context.tool_install() + .arg("black@24.1.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] + + black==24.1.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 + "###); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!(fs_err::read_to_string(tool_dir.join("black").join("uv-receipt.toml")).unwrap(), @r###" + [tool] + requirements = [{ name = "black", specifier = "==24.1.0" }] + entrypoints = [ + { name = "black", install-path = "[TEMP_DIR]/bin/black" }, + { name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" }, + ] + + [tool.options] + exclude-newer = "2024-03-25T00:00:00Z" + "###); + }); + + // Combining `{package}@{version}` with a `--from` should fail (even if they're ultimately + // compatible). + uv_snapshot!(context.filters(), context.tool_install() + .arg("black@24.1.0") + .arg("--from") + .arg("black==24.1.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: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Package requirement (`black==24.1.0`) provided with `--from` conflicts with install request (`black@24.1.0`) + "###); +} + +/// Test installing a tool with `uv tool install {package}@latest`. +#[test] +fn tool_install_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"); + + // Install `black` at latest. + uv_snapshot!(context.filters(), context.tool_install() + .arg("black@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] + + 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 + "###); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!(fs_err::read_to_string(tool_dir.join("black").join("uv-receipt.toml")).unwrap(), @r###" + [tool] + requirements = [{ name = "black" }] + entrypoints = [ + { name = "black", install-path = "[TEMP_DIR]/bin/black" }, + { name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" }, + ] + + [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() { + 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==24.1.1") + .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.1.1 + + 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 + "###); + + 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 = [{ name = "black", specifier = "==24.1.1" }] + entrypoints = [ + { name = "black", install-path = "[TEMP_DIR]/bin/black" }, + { name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" }, + ] + + [tool.options] + exclude-newer = "2024-03-25T00:00:00Z" + "###); + }); + + // Install without the constraint. It should be replaced, but the package shouldn't be installed + // since it's already satisfied in the environment. + uv_snapshot!(context.filters(), context.tool_install() + .arg("black") + .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 ----- + Installed 2 executables: black, blackd + "###); + + 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 = [{ name = "black" }] + entrypoints = [ + { name = "black", install-path = "[TEMP_DIR]/bin/black" }, + { name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" }, + ] + + [tool.options] + exclude-newer = "2024-03-25T00:00:00Z" + "###); + }); + + // Install with `{package}@{latest}`. `black` should be reinstalled with a more recent version. + uv_snapshot!(context.filters(), context.tool_install() + .arg("black@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] + Uninstalled [N] packages in [TIME] + Installed [N] packages in [TIME] + - black==24.1.1 + + black==24.3.0 + Installed 2 executables: black, blackd + "###); + + 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 = [{ name = "black" }] + entrypoints = [ + { name = "black", install-path = "[TEMP_DIR]/bin/black" }, + { name = "blackd", install-path = "[TEMP_DIR]/bin/blackd" }, + ] + + [tool.options] + exclude-newer = "2024-03-25T00:00:00Z" + "###); + }); +} diff --git a/docs/concepts/tools.md b/docs/concepts/tools.md index cf73c183f8ab..502d0cb5314e 100644 --- a/docs/concepts/tools.md +++ b/docs/concepts/tools.md @@ -101,6 +101,14 @@ $ uvx --isolated ruff --version 0.6.2 ``` +`uv tool install` will also respect the `{package}@{version}` and `{package}@latest` specifiers, as +in: + +```console +$ uv tool install ruff@latest +$ uv tool install ruff@0.6.0 +``` + ### Tools directory By default, the uv tools directory is named `tools` and is in the uv application state directory, diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 8465bbf9d25f..9fc444c73c8d 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1891,7 +1891,7 @@ Run a command provided by a Python package. 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 `@`, e.g., `uv run ruff@0.3.0`. If more complex version specification is desired or if the command is provided by a different package, use `--from`. +The name of the command can include an exact version in the format `@`, e.g., `uv tool run ruff@0.3.0`. If more complex version specification is desired or if the command is provided by a different package, use `--from`. If the tool was previously installed, i.e., via `uv tool install`, the installed version will be used unless a version is requested or the `--isolated` flag is used.