mirror of
https://github.com/astral-sh/uv.git
synced 2025-11-11 16:34:33 +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.
|
/// 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`.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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?
|
||||||
|
|
|
||||||
|
|
@ -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(_))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue