Bring parity to uvx and uv tool install requests (#11345)

## Summary

This PR refactors the whole `Target` abstraction, mostly to remove all
the repeated `From*` variants and logic in favor of a higher-level
struct that captures those details separately.

In doing so, it also adds support to `uvx` for unnamed requirements, for
parity with `uv tool install`. So, e.g., the following will show the
`flask` version:

```
uvx git+https://github.com/pallets/flask --version
```

I think this makes sense conceptually since we already support arbitrary
named requirements there.
This commit is contained in:
Charlie Marsh 2025-02-11 19:16:34 -05:00 committed by GitHub
parent 136593a1bb
commit 12d34e680e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 360 additions and 327 deletions

View file

@ -30,7 +30,7 @@ use crate::commands::project::{
EnvironmentSpecification, PlatformState, ProjectError,
};
use crate::commands::tool::common::{install_executables, refine_interpreter, remove_entrypoints};
use crate::commands::tool::Target;
use crate::commands::tool::{Target, ToolRequest};
use crate::commands::ExitStatus;
use crate::commands::{diagnostics, reporters::PythonDownloadReporter};
use crate::printer::Printer;
@ -95,105 +95,40 @@ pub(crate) async fn install(
.allow_insecure_host(allow_insecure_host.to_vec());
// Parse the input requirement.
let target = Target::parse(&package, from.as_deref());
let request = ToolRequest::parse(&package, from.as_deref());
// If the user passed, e.g., `ruff@latest`, refresh the cache.
let cache = if target.is_latest() {
let cache = if request.is_latest() {
cache.with_refresh(Refresh::All(Timestamp::now()))
} else {
cache
};
// Resolve the `--from` requirement.
let from = match target {
let from = match &request.target {
// Ex) `ruff`
Target::Unspecified(name) => {
Target::Unspecified(from) => {
let source = if editable {
RequirementsSource::Editable(name.to_string())
RequirementsSource::Editable((*from).to_string())
} else {
RequirementsSource::Package(name.to_string())
RequirementsSource::Package((*from).to_string())
};
let requirements = RequirementsSpecification::from_source(&source, &client_builder)
.await?
.requirements;
resolve_names(
requirements,
&interpreter,
&settings,
&state,
connectivity,
concurrency,
native_tls,
allow_insecure_host,
&cache,
printer,
preview,
)
.await?
.pop()
.unwrap()
}
// Ex) `ruff@0.6.0`
Target::Version(name, ref extras, ref version)
| Target::FromVersion(_, name, ref extras, ref version) => {
if editable {
bail!("`--editable` is only supported for local packages");
}
Requirement {
name: PackageName::from_str(name)?,
extras: extras.clone(),
groups: vec![],
marker: MarkerTree::default(),
source: RequirementSource::Registry {
specifier: VersionSpecifiers::from(VersionSpecifier::equals_version(
version.clone(),
)),
index: None,
conflict: None,
},
origin: None,
}
}
// Ex) `ruff@latest`
Target::Latest(name, ref extras) | Target::FromLatest(_, name, ref extras) => {
if editable {
bail!("`--editable` is only supported for local packages");
}
Requirement {
name: PackageName::from_str(name)?,
extras: extras.clone(),
groups: vec![],
marker: MarkerTree::default(),
source: RequirementSource::Registry {
specifier: VersionSpecifiers::empty(),
index: None,
conflict: None,
},
origin: None,
}
}
// Ex) `ruff>=0.6.0`
Target::From(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)
let requirement = RequirementsSpecification::from_source(&source, &client_builder)
.await?
.requirements;
// Parse the `--from` requirement.
let from_requirement = resolve_names(
requirements,
// If the user provided an executable name, verify that it matches the `--from` requirement.
let executable = if let Some(executable) = request.executable {
let Ok(executable) = PackageName::from_str(executable) else {
bail!("Package requirement (`{from}`) provided with `--from` conflicts with install request (`{executable}`)", from = from.cyan(), executable = executable.cyan())
};
Some(executable)
} else {
None
};
let requirement = resolve_names(
requirement,
&interpreter,
&settings,
&state,
@ -209,17 +144,58 @@ pub(crate) async fn install(
.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()
);
// Determine if it's an entirely different package (e.g., `uv install foo --from bar`).
if let Some(executable) = executable {
if requirement.name != executable {
bail!(
"Package name (`{}`) provided with `--from` does not match install request (`{}`)",
requirement.name.cyan(),
executable.cyan()
);
}
}
from_requirement
requirement
}
// Ex) `ruff@0.6.0`
Target::Version(.., name, ref extras, ref version) => {
if editable {
bail!("`--editable` is only supported for local packages");
}
Requirement {
name: name.clone(),
extras: extras.clone(),
groups: vec![],
marker: MarkerTree::default(),
source: RequirementSource::Registry {
specifier: VersionSpecifiers::from(VersionSpecifier::equals_version(
version.clone(),
)),
index: None,
conflict: None,
},
origin: None,
}
}
// Ex) `ruff@latest`
Target::Latest(.., name, ref extras) => {
if editable {
bail!("`--editable` is only supported for local packages");
}
Requirement {
name: name.clone(),
extras: extras.clone(),
groups: vec![],
marker: MarkerTree::default(),
source: RequirementSource::Registry {
specifier: VersionSpecifiers::empty(),
index: None,
conflict: None,
},
origin: None,
}
}
};
@ -232,7 +208,7 @@ pub(crate) async fn install(
}
// If the user passed, e.g., `ruff@latest`, we need to mark it as upgradable.
let settings = if target.is_latest() {
let settings = if request.is_latest() {
ResolverInstallerSettings {
upgrade: settings
.upgrade
@ -368,7 +344,7 @@ pub(crate) async fn install(
.as_ref()
.filter(|_| {
// And the user didn't request a reinstall or upgrade...
!target.is_latest() && settings.reinstall.is_none() && settings.upgrade.is_none()
!request.is_latest() && settings.reinstall.is_none() && settings.upgrade.is_none()
})
.is_some()
{

View file

@ -14,84 +14,62 @@ pub(crate) mod uninstall;
pub(crate) mod update_shell;
pub(crate) mod upgrade;
/// A request to run or install a tool (e.g., `uvx ruff@latest`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ToolRequest<'a> {
/// The executable name (e.g., `ruff`), if specified explicitly.
pub(crate) executable: Option<&'a str>,
/// The target to install or run (e.g., `ruff@latest` or `ruff==0.6.0`).
pub(crate) target: Target<'a>,
}
impl<'a> ToolRequest<'a> {
/// Parse a tool request into an executable name and a target.
pub(crate) fn parse(command: &'a str, from: Option<&'a str>) -> Self {
if let Some(from) = from {
let target = Target::parse(from);
Self {
executable: Some(command),
target,
}
} else {
let target = Target::parse(command);
Self {
executable: None,
target,
}
}
}
/// Returns whether the target package is Python.
pub(crate) fn is_python(&self) -> bool {
let name = match self.target {
Target::Unspecified(name) => name,
Target::Version(name, ..) => name,
Target::Latest(name, ..) => name,
};
name.eq_ignore_ascii_case("python") || cfg!(windows) && name.eq_ignore_ascii_case("pythonw")
}
/// Returns `true` if the target is `latest`.
pub(crate) fn is_latest(&self) -> bool {
matches!(self.target, Target::Latest(..))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Target<'a> {
/// e.g., `ruff`
Unspecified(&'a str),
/// e.g., `ruff[extra]@0.6.0`
Version(&'a str, Vec<ExtraName>, Version),
Version(&'a str, PackageName, Vec<ExtraName>, Version),
/// e.g., `ruff[extra]@latest`
Latest(&'a str, Vec<ExtraName>),
/// e.g., `ruff --from ruff[extra]>=0.6.0`
From(&'a str, &'a str),
/// e.g., `ruff --from ruff[extra]@0.6.0`
FromVersion(&'a str, &'a str, Vec<ExtraName>, Version),
/// e.g., `ruff --from ruff[extra]@latest`
FromLatest(&'a str, &'a str, Vec<ExtraName>),
Latest(&'a str, PackageName, Vec<ExtraName>),
}
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 {
// e.g. `--from ruff`, no special handling
let Some((name, version)) = from.split_once('@') else {
return Self::From(target, from);
};
// e.g. `--from ruff@`, warn and treat the whole thing as the command
if version.is_empty() {
debug!("Ignoring empty version request in `--from`");
return Self::From(target, from);
}
// Split into name and extras (e.g., `flask[dotenv]`).
let (name, extras) = match name.split_once('[') {
Some((name, extras)) => {
let Some(extras) = extras.strip_suffix(']') else {
// e.g., ignore `flask[dotenv`.
debug!("Ignoring invalid extras in `--from`");
return Self::From(target, from);
};
(name, extras)
}
None => (name, ""),
};
// 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 `--from`");
return Self::From(target, from);
}
// e.g., ignore `ruff[1.0.0]` or any other invalid extra.
let Ok(extras) = extras
.split(',')
.map(str::trim)
.filter(|extra| !extra.is_empty())
.map(ExtraName::from_str)
.collect::<Result<Vec<_>, _>>()
else {
debug!("Ignoring invalid extras `{extras}` in `--from`");
return Self::From(target, from);
};
match version {
// e.g., `ruff@latest`
"latest" => return Self::FromLatest(target, name, extras),
// e.g., `ruff@0.6.0`
version => {
if let Ok(version) = Version::from_str(version) {
return Self::FromVersion(target, name, extras, version);
}
}
};
// e.g. `--from ruff@invalid`, warn and treat the whole thing as the command
debug!("Ignoring invalid version request `{version}` in `--from`");
return Self::From(target, from);
}
pub(crate) fn parse(target: &'a str) -> Self {
// e.g. `ruff`, no special handling
let Some((name, version)) = target.split_once('@') else {
return Self::Unspecified(target);
@ -104,22 +82,22 @@ impl<'a> Target<'a> {
}
// Split into name and extras (e.g., `flask[dotenv]`).
let (name, extras) = match name.split_once('[') {
Some((name, extras)) => {
let (executable, extras) = match name.split_once('[') {
Some((executable, extras)) => {
let Some(extras) = extras.strip_suffix(']') else {
// e.g., ignore `flask[dotenv`.
return Self::Unspecified(name);
return Self::Unspecified(target);
};
(name, extras)
(executable, extras)
}
None => (name, ""),
};
// e.g., ignore `git+https://github.com/astral-sh/ruff.git@main`
if PackageName::from_str(name).is_err() {
let Ok(name) = PackageName::from_str(executable) else {
debug!("Ignoring non-package name `{name}` in command");
return Self::Unspecified(target);
}
};
// e.g., ignore `ruff[1.0.0]` or any other invalid extra.
let Ok(extras) = extras
@ -135,65 +113,19 @@ impl<'a> Target<'a> {
match version {
// e.g., `ruff@latest`
"latest" => return Self::Latest(name, extras),
"latest" => Self::Latest(executable, name, extras),
// e.g., `ruff@0.6.0`
version => {
if let Ok(version) = Version::from_str(version) {
return Self::Version(name, extras, version);
Self::Version(executable, name, extras, version)
} else {
// e.g. `ruff@invalid`, warn and treat the whole thing as the command
debug!("Ignoring invalid version request `{version}` in command");
Self::Unspecified(target)
}
}
};
// 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) => {
// Identify the package name from the PEP 508 specifier.
//
// For example, given `ruff>=0.6.0`, extract `ruff`, to use as the executable name.
let index = name
.find(|c| !matches!(c, 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.'))
.unwrap_or(name.len());
&name[..index]
}
Self::Version(name, _, _) => name,
Self::Latest(name, _) => name,
Self::FromVersion(name, _, _, _) => name,
Self::FromLatest(name, _, _) => name,
Self::From(name, _) => name,
}
}
/// Returns whether the target package is Python.
pub(crate) fn is_python(&self) -> bool {
let name = match self {
Self::Unspecified(name) => {
// Identify the package name from the PEP 508 specifier.
//
// For example, given `ruff>=0.6.0`, extract `ruff`, to use as the executable name.
let index = name
.find(|c| !matches!(c, 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.'))
.unwrap_or(name.len());
&name[..index]
}
Self::Version(name, _, _) => name,
Self::Latest(name, _) => name,
Self::FromVersion(_, name, _, _) => name,
Self::FromLatest(_, name, _) => name,
Self::From(_, name) => name,
};
name.eq_ignore_ascii_case("python") || cfg!(windows) && name.eq_ignore_ascii_case("pythonw")
}
/// Returns `true` if the target is `latest`.
fn is_latest(&self) -> bool {
matches!(self, Self::Latest(..) | Self::FromLatest(..))
}
}
#[cfg(test)]
@ -202,41 +134,56 @@ mod tests {
#[test]
fn parse_target() {
let target = Target::parse("flask", None);
let target = Target::parse("flask");
let expected = Target::Unspecified("flask");
assert_eq!(target, expected);
let target = Target::parse("flask@3.0.0", None);
let expected = Target::Version("flask", vec![], Version::new([3, 0, 0]));
assert_eq!(target, expected);
let target = Target::parse("flask@3.0.0", None);
let expected = Target::Version("flask", vec![], Version::new([3, 0, 0]));
assert_eq!(target, expected);
let target = Target::parse("flask@latest", None);
let expected = Target::Latest("flask", vec![]);
assert_eq!(target, expected);
let target = Target::parse("flask[dotenv]@3.0.0", None);
let target = Target::parse("flask@3.0.0");
let expected = Target::Version(
"flask",
PackageName::from_str("flask").unwrap(),
vec![],
Version::new([3, 0, 0]),
);
assert_eq!(target, expected);
let target = Target::parse("flask@3.0.0");
let expected = Target::Version(
"flask",
PackageName::from_str("flask").unwrap(),
vec![],
Version::new([3, 0, 0]),
);
assert_eq!(target, expected);
let target = Target::parse("flask@latest");
let expected = Target::Latest("flask", PackageName::from_str("flask").unwrap(), vec![]);
assert_eq!(target, expected);
let target = Target::parse("flask[dotenv]@3.0.0");
let expected = Target::Version(
"flask",
PackageName::from_str("flask").unwrap(),
vec![ExtraName::from_str("dotenv").unwrap()],
Version::new([3, 0, 0]),
);
assert_eq!(target, expected);
let target = Target::parse("flask[dotenv]@latest", None);
let expected = Target::Latest("flask", vec![ExtraName::from_str("dotenv").unwrap()]);
let target = Target::parse("flask[dotenv]@latest");
let expected = Target::Latest(
"flask",
PackageName::from_str("flask").unwrap(),
vec![ExtraName::from_str("dotenv").unwrap()],
);
assert_eq!(target, expected);
// Missing a closing `]`.
let target = Target::parse("flask[dotenv", None);
let target = Target::parse("flask[dotenv");
let expected = Target::Unspecified("flask[dotenv");
assert_eq!(target, expected);
// Too many `]`.
let target = Target::parse("flask[dotenv]]", None);
let target = Target::parse("flask[dotenv]]");
let expected = Target::Unspecified("flask[dotenv]]");
assert_eq!(target, expected);
}

View file

@ -15,8 +15,7 @@ use uv_cache_info::Timestamp;
use uv_cli::ExternalCommand;
use uv_client::{BaseClientBuilder, Connectivity};
use uv_configuration::{Concurrency, PreviewMode, TrustedHost};
use uv_distribution_types::UnresolvedRequirement;
use uv_distribution_types::{Name, UnresolvedRequirementSpecification};
use uv_distribution_types::{Name, UnresolvedRequirement, UnresolvedRequirementSpecification};
use uv_installer::{SatisfiesResult, SitePackages};
use uv_normalize::PackageName;
use uv_pep440::{VersionSpecifier, VersionSpecifiers};
@ -42,7 +41,7 @@ use crate::commands::project::{
use crate::commands::reporters::PythonDownloadReporter;
use crate::commands::run::run_to_completion;
use crate::commands::tool::common::{matching_packages, refine_interpreter};
use crate::commands::tool::Target;
use crate::commands::tool::{Target, ToolRequest};
use crate::commands::ExitStatus;
use crate::commands::{diagnostics, project::environment::CachedEnvironment};
use crate::printer::Printer;
@ -105,10 +104,10 @@ pub(crate) async fn run(
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());
let request = ToolRequest::parse(target, from.as_deref());
// If the user passed, e.g., `ruff@latest`, refresh the cache.
let cache = if target.is_latest() {
let cache = if request.is_latest() {
cache.with_refresh(Refresh::All(Timestamp::now()))
} else {
cache
@ -116,7 +115,7 @@ pub(crate) async fn run(
// Get or create a compatible environment in which to execute the tool.
let result = get_or_create_environment(
&target,
&request,
with,
show_resolution,
python.as_deref(),
@ -154,7 +153,7 @@ pub(crate) async fn run(
};
// TODO(zanieb): Determine the executable command via the package entry points
let executable = target.executable();
let executable = from.executable();
// Construct the command
let mut process = Command::new(executable);
@ -187,7 +186,9 @@ pub(crate) async fn run(
// If the command is found in other packages, we warn the user about the correct package to use.
match &from {
ToolRequirement::Python => {}
ToolRequirement::Package(from) => {
ToolRequirement::Package {
requirement: from, ..
} => {
warn_executable_not_provided_by_package(
executable,
&from.name,
@ -230,7 +231,9 @@ fn hint_on_not_found(
) -> anyhow::Result<Option<ExitStatus>> {
let from = match from {
ToolRequirement::Python => return Ok(None),
ToolRequirement::Package(from) => from,
ToolRequirement::Package {
requirement: from, ..
} => from,
};
match get_entrypoints(&from.name, site_packages) {
Ok(entrypoints) => {
@ -413,14 +416,26 @@ fn warn_executable_not_provided_by_package(
#[allow(clippy::large_enum_variant)]
pub(crate) enum ToolRequirement {
Python,
Package(Requirement),
Package {
executable: String,
requirement: Requirement,
},
}
impl ToolRequirement {
fn executable(&self) -> &str {
match self {
ToolRequirement::Python => "python",
ToolRequirement::Package { executable, .. } => executable,
}
}
}
impl std::fmt::Display for ToolRequirement {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ToolRequirement::Python => write!(f, "python"),
ToolRequirement::Package(requirement) => write!(f, "{requirement}"),
ToolRequirement::Package { requirement, .. } => write!(f, "{requirement}"),
}
}
}
@ -431,7 +446,7 @@ impl std::fmt::Display for ToolRequirement {
/// [`PythonEnvironment`]. Otherwise, gets or creates a [`CachedEnvironment`].
#[allow(clippy::fn_params_excessive_bools)]
async fn get_or_create_environment(
target: &Target<'_>,
request: &ToolRequest<'_>,
with: &[RequirementsSource],
show_resolution: bool,
python: Option<&str>,
@ -457,26 +472,19 @@ async fn get_or_create_environment(
let reporter = PythonDownloadReporter::single(printer);
// Check if the target is `python`
let python_request = if target.is_python() {
let target_request = match target {
let python_request = if request.is_python() {
let target_request = match &request.target {
Target::Unspecified(_) => None,
Target::Version(_, _, version) | Target::FromVersion(_, _, _, version) => {
Some(PythonRequest::Version(
VersionRequest::from_str(&version.to_string()).map_err(anyhow::Error::from)?,
))
}
Target::Version(_, _, _, version) => Some(PythonRequest::Version(
VersionRequest::from_str(&version.to_string()).map_err(anyhow::Error::from)?,
)),
// TODO(zanieb): Add `PythonRequest::Latest`
Target::Latest(_, _) | Target::FromLatest(_, _, _) => {
Target::Latest(_, _, _) => {
return Err(anyhow::anyhow!(
"Requesting the 'latest' Python version is not yet supported"
)
.into())
}
// From the definition of `is_python`, this can only be a bare `python`
Target::From(_, from) => {
debug_assert_eq!(*from, "python");
None
}
};
if let Some(target_request) = &target_request {
@ -513,65 +521,14 @@ async fn get_or_create_environment(
// Initialize any shared state.
let state = PlatformState::default();
let from = if target.is_python() {
let from = if request.is_python() {
ToolRequirement::Python
} else {
ToolRequirement::Package(match target {
// Ex) `ruff`
Target::Unspecified(name) => {
let source = RequirementsSource::Package((*name).to_string());
let requirements = RequirementsSpecification::from_source(&source, &client_builder)
.await?
.requirements;
resolve_names(
requirements,
&interpreter,
settings,
&state,
connectivity,
concurrency,
native_tls,
allow_insecure_host,
cache,
printer,
preview,
)
.await?
.pop()
.unwrap()
}
// Ex) `ruff@0.6.0`
Target::Version(name, extras, version)
| Target::FromVersion(_, name, extras, version) => Requirement {
name: PackageName::from_str(name)?,
extras: extras.clone(),
groups: vec![],
marker: MarkerTree::default(),
source: RequirementSource::Registry {
specifier: VersionSpecifiers::from(VersionSpecifier::equals_version(
version.clone(),
)),
index: None,
conflict: None,
},
origin: None,
},
// Ex) `ruff@latest`
Target::Latest(name, extras) | Target::FromLatest(_, name, extras) => Requirement {
name: PackageName::from_str(name)?,
extras: extras.clone(),
groups: vec![],
marker: MarkerTree::default(),
source: RequirementSource::Registry {
specifier: VersionSpecifiers::empty(),
index: None,
conflict: None,
},
origin: None,
},
let (executable, requirement) = match &request.target {
// Ex) `ruff>=0.6.0`
Target::From(_, from) => {
let spec = RequirementsSpecification::parse_package(from)?;
Target::Unspecified(requirement) => {
let spec = RequirementsSpecification::parse_package(requirement)?;
debug!("{:?}", spec);
if let UnresolvedRequirement::Named(requirement) = &spec.requirement {
if requirement.name.as_str() == "python" {
return Err(anyhow::anyhow!(
@ -582,7 +539,7 @@ async fn get_or_create_environment(
.into());
}
}
resolve_names(
let requirement = resolve_names(
vec![spec],
&interpreter,
settings,
@ -597,9 +554,58 @@ async fn get_or_create_environment(
)
.await?
.pop()
.unwrap()
.unwrap();
// Use the executable provided by the user, if possible (as in: `uvx --from package executable`).
//
// If no such executable was provided, rely on the package name (as in: `uvx git+https://github.com/pallets/flask`).
let executable = request
.executable
.map(ToString::to_string)
.unwrap_or_else(|| requirement.name.to_string());
(executable, requirement)
}
})
// Ex) `ruff@0.6.0`
Target::Version(executable, name, extras, version) => (
(*executable).to_string(),
Requirement {
name: name.clone(),
extras: extras.clone(),
groups: vec![],
marker: MarkerTree::default(),
source: RequirementSource::Registry {
specifier: VersionSpecifiers::from(VersionSpecifier::equals_version(
version.clone(),
)),
index: None,
conflict: None,
},
origin: None,
},
),
// Ex) `ruff@latest`
Target::Latest(executable, name, extras) => (
(*executable).to_string(),
Requirement {
name: name.clone(),
extras: extras.clone(),
groups: vec![],
marker: MarkerTree::default(),
source: RequirementSource::Registry {
specifier: VersionSpecifiers::empty(),
index: None,
conflict: None,
},
origin: None,
},
),
};
ToolRequirement::Package {
executable,
requirement,
}
};
// Read the `--with` requirements.
@ -616,7 +622,7 @@ async fn get_or_create_environment(
let mut requirements = Vec::with_capacity(1 + with.len());
match &from {
ToolRequirement::Python => {}
ToolRequirement::Package(requirement) => requirements.push(requirement.clone()),
ToolRequirement::Package { requirement, .. } => requirements.push(requirement.clone()),
}
requirements.extend(
resolve_names(
@ -638,11 +644,11 @@ async fn get_or_create_environment(
};
// Check if the tool is already installed in a compatible environment.
if !isolated && !target.is_latest() {
if !isolated && !request.is_latest() {
let installed_tools = InstalledTools::from_settings()?.init()?;
let _lock = installed_tools.lock().await?;
if let ToolRequirement::Package(requirement) = &from {
if let ToolRequirement::Package { requirement, .. } = &from {
let existing_environment = installed_tools
.get_environment(&requirement.name, cache)?
.filter(|environment| {

View file

@ -3351,3 +3351,57 @@ fn tool_install_python() {
error: Cannot install Python with `uv tool install`. Did you mean to use `uv python install`?
"###);
}
#[test]
fn tool_install_mismatched_name() {
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");
uv_snapshot!(context.filters(), context.tool_install()
.arg("black")
.arg("--from")
.arg("https://files.pythonhosted.org/packages/af/47/93213ee66ef8fae3b93b3e29206f6b251e65c97bd91d8e1c5596ef15af0a/flask-3.1.0-py3-none-any.whl")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::PATH, bin_dir.as_os_str()), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Package name (`flask`) provided with `--from` does not match install request (`black`)
"###);
uv_snapshot!(context.filters(), context.tool_install()
.arg("black")
.arg("--from")
.arg("flask @ https://files.pythonhosted.org/packages/af/47/93213ee66ef8fae3b93b3e29206f6b251e65c97bd91d8e1c5596ef15af0a/flask-3.1.0-py3-none-any.whl")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::PATH, bin_dir.as_os_str()), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Package name (`flask`) provided with `--from` does not match install request (`black`)
"###);
uv_snapshot!(context.filters(), context.tool_install()
.arg("flask")
.arg("--from")
.arg("black @ https://files.pythonhosted.org/packages/af/47/93213ee66ef8fae3b93b3e29206f6b251e65c97bd91d8e1c5596ef15af0a/flask-3.1.0-py3-none-any.whl")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::PATH, bin_dir.as_os_str()), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Package name (`black`) provided with `--from` does not match install request (`flask`)
"###);
}

View file

@ -655,6 +655,56 @@ fn tool_run_url() {
+ markupsafe==2.1.5
+ werkzeug==3.0.1
"###);
uv_snapshot!(context.filters(), context.tool_run()
.arg("--from")
.arg("https://files.pythonhosted.org/packages/61/80/ffe1da13ad9300f87c93af113edd0638c75138c42a0994becfacac078c06/flask-3.0.3-py3-none-any.whl")
.arg("flask")
.arg("--version")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r###"
success: true
exit_code: 0
----- stdout -----
Python 3.12.[X]
Flask 3.0.3
Werkzeug 3.0.1
----- stderr -----
Resolved [N] packages in [TIME]
"###);
uv_snapshot!(context.filters(), context.tool_run()
.arg("flask @ https://files.pythonhosted.org/packages/61/80/ffe1da13ad9300f87c93af113edd0638c75138c42a0994becfacac078c06/flask-3.0.3-py3-none-any.whl")
.arg("--version")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r###"
success: true
exit_code: 0
----- stdout -----
Python 3.12.[X]
Flask 3.0.3
Werkzeug 3.0.1
----- stderr -----
Resolved [N] packages in [TIME]
"###);
uv_snapshot!(context.filters(), context.tool_run()
.arg("https://files.pythonhosted.org/packages/61/80/ffe1da13ad9300f87c93af113edd0638c75138c42a0994becfacac078c06/flask-3.0.3-py3-none-any.whl")
.arg("--version")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r###"
success: true
exit_code: 0
----- stdout -----
Python 3.12.[X]
Flask 3.0.3
Werkzeug 3.0.1
----- stderr -----
Resolved [N] packages in [TIME]
"###);
}
/// Read requirements from a `requirements.txt` file.