mirror of
https://github.com/astral-sh/uv.git
synced 2025-08-04 19:08:04 +00:00
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:
parent
af323888ee
commit
cef3d35405
9 changed files with 505 additions and 159 deletions
|
@ -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`.
|
||||
///
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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(_))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -827,7 +827,7 @@ async fn run(cli: Cli) -> Result<ExitStatus> {
|
|||
globals.connectivity,
|
||||
globals.concurrency,
|
||||
globals.native_tls,
|
||||
&cache,
|
||||
cache,
|
||||
printer,
|
||||
)
|
||||
.await
|
||||
|
|
|
@ -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"
|
||||
"###);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue