Skip to content

Commit

Permalink
Support {package}@{version} in uv tool install
Browse files Browse the repository at this point in the history
  • Loading branch information
charliermarsh committed Aug 28, 2024
1 parent bb52997 commit ad2a21f
Show file tree
Hide file tree
Showing 8 changed files with 495 additions and 157 deletions.
2 changes: 1 addition & 1 deletion crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 [email protected]`. If more complex
/// `<package>@<version>`, e.g., `uv tool run [email protected]`. If more complex
/// version specification is desired or if the command is provided by a
/// different package, use `--from`.
///
Expand Down
30 changes: 29 additions & 1 deletion crates/uv-configuration/src/package_options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand 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)
Expand Down Expand Up @@ -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.
Expand Down
225 changes: 147 additions & 78 deletions crates/uv/src/commands/tool/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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()
Expand All @@ -63,7 +66,7 @@ pub(crate) async fn install(
python_preference,
python_downloads,
&client_builder,
cache,
&cache,
Some(&reporter),
)
.await?
Expand All @@ -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,
Expand All @@ -102,49 +109,106 @@ pub(crate) async fn install(
connectivity,
concurrency,
native_tls,
cache,
&cache,
printer,
)
.await?
.pop()
.unwrap()
};
}
// Ex) `[email protected]`
Target::Version(name, ref version) => {
if editable {
bail!("`--editable` is only supported for local packages");
}

// 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()
);
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");
}

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::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();

// 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()
);
}

resolve_names(
requirements,
&interpreter,
&settings,
&state,
connectivity,
concurrency,
native_tls,
cache,
printer,
)
.await?
.pop()
.unwrap()
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.
Expand All @@ -163,7 +227,7 @@ pub(crate) async fn install(
connectivity,
concurrency,
native_tls,
cache,
&cache,
printer,
)
.await?,
Expand Down Expand Up @@ -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 {
Expand All @@ -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),
)?;
}
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()
)?;
// 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);
}
return Ok(ExitStatus::Success);
}
}
}
Expand Down Expand Up @@ -279,7 +348,7 @@ pub(crate) async fn install(
connectivity,
concurrency,
native_tls,
cache,
&cache,
printer,
)
.await?
Expand All @@ -304,7 +373,7 @@ pub(crate) async fn install(
connectivity,
concurrency,
native_tls,
cache,
&cache,
printer,
)
.await?;
Expand All @@ -327,7 +396,7 @@ pub(crate) async fn install(
connectivity,
concurrency,
native_tls,
cache,
&cache,
printer,
)
.await?
Expand Down
Loading

0 comments on commit ad2a21f

Please sign in to comment.