Support {package}@{version} in uv tool install (#6762)

## Summary

Closes https://github.com/astral-sh/uv/issues/6759.

Closes https://github.com/astral-sh/uv/issues/6535.
This commit is contained in:
Charlie Marsh 2024-08-28 12:40:49 -04:00 committed by GitHub
parent af323888ee
commit cef3d35405
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 505 additions and 159 deletions

View file

@ -2758,7 +2758,7 @@ pub enum ToolCommand {
/// By default, the package to install is assumed to match the command name. /// 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 /// The name of the command can include an exact version in the format
/// `<package>@<version>`, e.g., `uv run ruff@0.3.0`. If more complex /// `<package>@<version>`, e.g., `uv tool run ruff@0.3.0`. If more complex
/// version specification is desired or if the command is provided by a /// version specification is desired or if the command is provided by a
/// different package, use `--from`. /// different package, use `--from`.
/// ///

View file

@ -74,7 +74,7 @@ pub enum Upgrade {
} }
impl 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<bool>, upgrade_package: Vec<Requirement>) -> Self { pub fn from_args(upgrade: Option<bool>, upgrade_package: Vec<Requirement>) -> Self {
match upgrade { match upgrade {
Some(true) => Self::All, 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. /// Returns `true` if no packages should be upgraded.
pub fn is_none(&self) -> bool { pub fn is_none(&self) -> bool {
matches!(self, Self::None) matches!(self, Self::None)
@ -130,6 +139,25 @@ impl Upgrade {
Either::Left(std::iter::empty()) 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. /// Create a [`Refresh`] policy by integrating the [`Upgrade`] policy.

View file

@ -4,11 +4,13 @@ use std::str::FromStr;
use anyhow::{bail, Result}; use anyhow::{bail, Result};
use distribution_types::UnresolvedRequirementSpecification; use distribution_types::UnresolvedRequirementSpecification;
use owo_colors::OwoColorize; use owo_colors::OwoColorize;
use pep440_rs::{VersionSpecifier, VersionSpecifiers};
use pep508_rs::MarkerTree;
use pypi_types::{Requirement, RequirementSource};
use tracing::debug; use tracing::debug;
use uv_cache::{Cache, Refresh, Timestamp};
use uv_cache::Cache;
use uv_client::{BaseClientBuilder, Connectivity}; use uv_client::{BaseClientBuilder, Connectivity};
use uv_configuration::Concurrency; use uv_configuration::{Concurrency, Upgrade};
use uv_normalize::PackageName; use uv_normalize::PackageName;
use uv_python::{ use uv_python::{
EnvironmentPreference, PythonDownloads, PythonInstallation, PythonPreference, PythonRequest, EnvironmentPreference, PythonDownloads, PythonInstallation, PythonPreference, PythonRequest,
@ -24,6 +26,7 @@ use crate::commands::project::{
resolve_environment, resolve_names, sync_environment, update_environment, resolve_environment, resolve_names, sync_environment, update_environment,
}; };
use crate::commands::tool::common::remove_entrypoints; use crate::commands::tool::common::remove_entrypoints;
use crate::commands::tool::Target;
use crate::commands::{reporters::PythonDownloadReporter, tool::common::install_executables}; use crate::commands::{reporters::PythonDownloadReporter, tool::common::install_executables};
use crate::commands::{ExitStatus, SharedState}; use crate::commands::{ExitStatus, SharedState};
use crate::printer::Printer; use crate::printer::Printer;
@ -44,7 +47,7 @@ pub(crate) async fn install(
connectivity: Connectivity, connectivity: Connectivity,
concurrency: Concurrency, concurrency: Concurrency,
native_tls: bool, native_tls: bool,
cache: &Cache, cache: Cache,
printer: Printer, printer: Printer,
) -> Result<ExitStatus> { ) -> Result<ExitStatus> {
let client_builder = BaseClientBuilder::new() let client_builder = BaseClientBuilder::new()
@ -63,7 +66,7 @@ pub(crate) async fn install(
python_preference, python_preference,
python_downloads, python_downloads,
&client_builder, &client_builder,
cache, &cache,
Some(&reporter), Some(&reporter),
) )
.await? .await?
@ -76,24 +79,28 @@ pub(crate) async fn install(
.connectivity(connectivity) .connectivity(connectivity)
.native_tls(native_tls); .native_tls(native_tls);
// Resolve the `from` requirement. // Parse the input requirement.
let from = if let Some(from) = from { let target = Target::parse(&package, from.as_deref());
// 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`). // If the user passed, e.g., `ruff@latest`, refresh the cache.
let Ok(package) = PackageName::from_str(&package) else { let cache = if target.is_latest() {
bail!("Package requirement (`{from}`) provided with `--from` conflicts with install request (`{package}`)", from = from.cyan(), package = package.cyan()) cache.with_refresh(Refresh::All(Timestamp::now()))
} else {
cache
}; };
// Resolve the `--from` requirement.
let from = match target {
// Ex) `ruff`
Target::Unspecified(name) => {
let source = if editable { let source = if editable {
RequirementsSource::Editable(from) RequirementsSource::Editable(name.to_string())
} else { } else {
RequirementsSource::Package(from) RequirementsSource::Package(name.to_string())
}; };
let requirements = RequirementsSpecification::from_source(&source, &client_builder) let requirements = RequirementsSpecification::from_source(&source, &client_builder)
.await? .await?
.requirements; .requirements;
let from_requirement = {
resolve_names( resolve_names(
requirements, requirements,
&interpreter, &interpreter,
@ -102,14 +109,82 @@ pub(crate) async fn install(
connectivity, connectivity,
concurrency, concurrency,
native_tls, native_tls,
cache, &cache,
printer, printer,
) )
.await? .await?
.pop() .pop()
.unwrap() .unwrap()
}
// Ex) `ruff@0.6.0`
Target::Version(name, ref version) => {
if editable {
bail!("`--editable` is only supported for local packages");
}
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");
}
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?
.pop()
.unwrap();
// Check if the positional name conflicts with `--from`. // Check if the positional name conflicts with `--from`.
if from_requirement.name != package { if from_requirement.name != package {
// Determine if it's an entirely different package (e.g., `uv install foo --from bar`). // Determine if it's an entirely different package (e.g., `uv install foo --from bar`).
@ -121,30 +196,19 @@ pub(crate) async fn install(
} }
from_requirement from_requirement
} else { }
let source = if editable {
RequirementsSource::Editable(package.clone())
} else {
RequirementsSource::Package(package.clone())
}; };
let requirements = RequirementsSpecification::from_source(&source, &client_builder)
.await?
.requirements;
resolve_names( // If the user passed, e.g., `ruff@latest`, we need to mark it as upgradable.
requirements, let settings = if target.is_latest() {
&interpreter, ResolverInstallerSettings {
&settings, upgrade: settings
&state, .upgrade
connectivity, .combine(Upgrade::package(from.name.clone())),
concurrency, ..settings
native_tls, }
cache, } else {
printer, settings
)
.await?
.pop()
.unwrap()
}; };
// Read the `--with` requirements. // Read the `--with` requirements.
@ -163,7 +227,7 @@ pub(crate) async fn install(
connectivity, connectivity,
concurrency, concurrency,
native_tls, native_tls,
cache, &cache,
printer, printer,
) )
.await?, .await?,
@ -209,10 +273,10 @@ pub(crate) async fn install(
let existing_environment = let existing_environment =
installed_tools installed_tools
.get_environment(&from.name, cache)? .get_environment(&from.name, &cache)?
.filter(|environment| { .filter(|environment| {
python_request.as_ref().map_or(true, |python_request| { 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()); debug!("Found existing environment for `{from}`", from = from.name.cyan());
true true
} else { } else {
@ -227,18 +291,24 @@ pub(crate) async fn install(
}); });
// If the requested and receipt requirements are the same... // 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() { if let Some(tool_receipt) = existing_tool_receipt.as_ref() {
let receipt = tool_receipt.requirements().to_vec(); let receipt = tool_receipt.requirements().to_vec();
if requirements == receipt { 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 { if *tool_receipt.options() != options {
// ...but the options differ, we need to update the receipt. // ...but the options differ, we need to update the receipt.
installed_tools.add_tool_receipt( installed_tools
&from.name, .add_tool_receipt(&from.name, tool_receipt.clone().with_options(options))?;
tool_receipt.clone().with_options(options),
)?;
} }
// We're done, though we might need to update the receipt. // We're done, though we might need to update the receipt.
@ -252,7 +322,6 @@ pub(crate) async fn install(
} }
} }
} }
}
// Create a `RequirementsSpecification` from the resolved requirements, to avoid re-resolving. // Create a `RequirementsSpecification` from the resolved requirements, to avoid re-resolving.
let spec = RequirementsSpecification { let spec = RequirementsSpecification {
@ -279,7 +348,7 @@ pub(crate) async fn install(
connectivity, connectivity,
concurrency, concurrency,
native_tls, native_tls,
cache, &cache,
printer, printer,
) )
.await? .await?
@ -304,7 +373,7 @@ pub(crate) async fn install(
connectivity, connectivity,
concurrency, concurrency,
native_tls, native_tls,
cache, &cache,
printer, printer,
) )
.await?; .await?;
@ -327,7 +396,7 @@ pub(crate) async fn install(
connectivity, connectivity,
concurrency, concurrency,
native_tls, native_tls,
cache, &cache,
printer, printer,
) )
.await? .await?

View file

@ -1,3 +1,10 @@
use std::str::FromStr;
use tracing::debug;
use pep440_rs::Version;
use uv_normalize::PackageName;
mod common; mod common;
pub(crate) mod dir; pub(crate) mod dir;
pub(crate) mod install; pub(crate) mod install;
@ -6,3 +13,71 @@ pub(crate) mod run;
pub(crate) mod uninstall; pub(crate) mod uninstall;
pub(crate) mod update_shell; pub(crate) mod update_shell;
pub(crate) mod upgrade; 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(_))
}
}

View file

@ -1,4 +1,3 @@
use std::ffi::OsString;
use std::fmt::Display; use std::fmt::Display;
use std::fmt::Write; use std::fmt::Write;
use std::path::PathBuf; use std::path::PathBuf;
@ -12,7 +11,7 @@ use tokio::process::Command;
use tracing::{debug, warn}; use tracing::{debug, warn};
use distribution_types::{Name, UnresolvedRequirementSpecification}; use distribution_types::{Name, UnresolvedRequirementSpecification};
use pep440_rs::{Version, VersionSpecifier, VersionSpecifiers}; use pep440_rs::{VersionSpecifier, VersionSpecifiers};
use pep508_rs::MarkerTree; use pep508_rs::MarkerTree;
use pypi_types::{Requirement, RequirementSource}; use pypi_types::{Requirement, RequirementSource};
use uv_cache::{Cache, Refresh, Timestamp}; use uv_cache::{Cache, Refresh, Timestamp};
@ -35,6 +34,7 @@ use crate::commands::pip::loggers::{
use crate::commands::pip::operations; use crate::commands::pip::operations;
use crate::commands::project::{resolve_names, ProjectError}; use crate::commands::project::{resolve_names, ProjectError};
use crate::commands::reporters::PythonDownloadReporter; use crate::commands::reporters::PythonDownloadReporter;
use crate::commands::tool::Target;
use crate::commands::{ use crate::commands::{
project::environment::CachedEnvironment, tool::common::matching_packages, tool_list, 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")); 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. // If the user passed, e.g., `ruff@latest`, refresh the cache.
let cache = if target.is_latest() { 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<Self> {
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. /// 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 /// If the target tool is already installed in a compatible environment, returns that

View file

@ -827,7 +827,7 @@ async fn run(cli: Cli) -> Result<ExitStatus> {
globals.connectivity, globals.connectivity,
globals.concurrency, globals.concurrency,
globals.native_tls, globals.native_tls,
&cache, cache,
printer, printer,
) )
.await .await

View file

@ -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"
"###);
});
}

View file

@ -101,6 +101,14 @@ $ uvx --isolated ruff --version
0.6.2 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 ### Tools directory
By default, the uv tools directory is named `tools` and is in the uv application state directory, By default, the uv tools directory is named `tools` and is in the uv application state directory,

View file

@ -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. 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>`, 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 `<package>@<version>`, 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. 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.