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.
///
/// 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
/// different package, use `--from`.
///

View file

@ -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<bool>, upgrade_package: Vec<Requirement>) -> 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.

View file

@ -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<ExitStatus> {
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()
};
// 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()
);
}
// Ex) `ruff@0.6.0`
Target::Version(name, ref version) => {
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::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?
.requirements;
.pop()
.unwrap();
resolve_names(
requirements,
&interpreter,
&settings,
&state,
connectivity,
concurrency,
native_tls,
cache,
printer,
)
.await?
.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()
);
}
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),
)?;
}
// 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);
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()
)?;
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?

View file

@ -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(_))
}
}

View file

@ -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<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.
///
/// 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.concurrency,
globals.native_tls,
&cache,
cache,
printer,
)
.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
```
`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,

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.
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.