Skip to content

Commit

Permalink
Add support for package@latest in tool run (#6138)
Browse files Browse the repository at this point in the history
## Summary

`@latest` will ignore any installed tools and force a cache refresh.

Closes #5807.
  • Loading branch information
charliermarsh authored Aug 19, 2024
1 parent c817f41 commit c80a831
Show file tree
Hide file tree
Showing 7 changed files with 221 additions and 59 deletions.
9 changes: 9 additions & 0 deletions crates/uv-cache/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1011,6 +1011,15 @@ impl Refresh {
}
}

/// Return the [`Timestamp`] associated with the refresh policy.
pub fn timestamp(&self) -> Timestamp {
match self {
Self::None(timestamp) => *timestamp,
Self::Packages(_, timestamp) => *timestamp,
Self::All(timestamp) => *timestamp,
}
}

/// Returns `true` if no packages should be reinstalled.
pub fn is_none(&self) -> bool {
matches!(self, Self::None(_))
Expand Down
3 changes: 3 additions & 0 deletions crates/uv/src/commands/project/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ pub(crate) enum ProjectError {
#[error(transparent)]
Tool(#[from] uv_tool::Error),

#[error(transparent)]
Name(#[from] uv_normalize::InvalidNameError),

#[error(transparent)]
NamedRequirements(#[from] uv_requirements::NamedRequirementsError),

Expand Down
183 changes: 131 additions & 52 deletions crates/uv/src/commands/tool/run.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use std::ffi::OsString;
use std::fmt::Display;
use std::fmt::Write;
use std::path::PathBuf;
use std::str::FromStr;
use std::{borrow::Cow, fmt::Display};

use anstream::eprint;
use anyhow::{bail, Context};
Expand All @@ -12,9 +12,10 @@ use tokio::process::Command;
use tracing::{debug, warn};

use distribution_types::{Name, UnresolvedRequirementSpecification};
use pep440_rs::Version;
use pypi_types::Requirement;
use uv_cache::Cache;
use pep440_rs::{Version, VersionSpecifier, VersionSpecifiers};
use pep508_rs::MarkerTree;
use pypi_types::{Requirement, RequirementSource};
use uv_cache::{Cache, Refresh, Timestamp};
use uv_cli::ExternalCommand;
use uv_client::{BaseClientBuilder, Connectivity};
use uv_configuration::{Concurrency, PreviewMode};
Expand Down Expand Up @@ -75,7 +76,7 @@ pub(crate) async fn run(
connectivity: Connectivity,
concurrency: Concurrency,
native_tls: bool,
cache: &Cache,
cache: Cache,
printer: Printer,
) -> anyhow::Result<ExitStatus> {
if preview.is_disabled() {
Expand All @@ -84,23 +85,26 @@ pub(crate) async fn run(

// treat empty command as `uv tool list`
let Some(command) = command else {
return tool_list(false, PreviewMode::Enabled, cache, printer).await;
return tool_list(false, PreviewMode::Enabled, &cache, printer).await;
};

let (target, args) = command.split();
let Some(target) = target else {
return Err(anyhow::anyhow!("No tool command provided"));
};

let (target, from) = if let Some(from) = from {
(Cow::Borrowed(target), Cow::Owned(from))
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() {
cache.with_refresh(Refresh::All(Timestamp::now()))
} else {
parse_target(target)?
cache
};

// Get or create a compatible environment in which to execute the tool.
let result = get_or_create_environment(
&from,
&target,
with,
show_resolution,
python.as_deref(),
Expand All @@ -112,7 +116,7 @@ pub(crate) async fn run(
connectivity,
concurrency,
native_tls,
cache,
&cache,
printer,
)
.await;
Expand All @@ -131,10 +135,10 @@ pub(crate) async fn run(
};

// TODO(zanieb): Determine the executable command via the package entry points
let executable = target;
let executable = target.executable();

// Construct the command
let mut process = Command::new(executable.as_ref());
let mut process = Command::new(executable);
process.args(args);

// Construct the `PATH` environment variable.
Expand All @@ -154,17 +158,16 @@ pub(crate) async fn run(
let space = if args.is_empty() { "" } else { " " };
debug!(
"Running `{}{space}{}`",
executable.to_string_lossy(),
executable,
args.iter().map(|arg| arg.to_string_lossy()).join(" ")
);

let site_packages = SitePackages::from_environment(&environment)?;

// We check if the provided command is not part of the executables for the `from` package.
// If the command is found in other packages, we warn the user about the correct package to use.

warn_executable_not_provided_by_package(
&executable.to_string_lossy(),
executable,
&from.name,
&site_packages,
invocation_source,
Expand All @@ -178,7 +181,7 @@ pub(crate) async fn run(
writeln!(
printer.stdout(),
"The executable `{}` was not found.",
executable.to_string_lossy().cyan(),
executable.cyan(),
)?;
if entrypoints.is_empty() {
warn_user!(
Expand All @@ -188,7 +191,7 @@ pub(crate) async fn run(
} else {
warn_user!(
"An executable named `{}` is not provided by package `{}`.",
executable.to_string_lossy().cyan(),
executable.cyan(),
from.name.red()
);
writeln!(
Expand All @@ -210,7 +213,7 @@ pub(crate) async fn run(
}
Err(err) => Err(err),
}
.with_context(|| format!("Failed to spawn: `{}`", executable.to_string_lossy()))?;
.with_context(|| format!("Failed to spawn: `{executable}`"))?;

// Ignore signals in the parent process, deferring them to the child. This is safe as long as
// the command is the last thing that runs in this process; otherwise, we'd need to restore the
Expand Down Expand Up @@ -294,48 +297,84 @@ fn warn_executable_not_provided_by_package(
}
}

/// Parse a target into a command name and a requirement.
fn parse_target(target: &OsString) -> anyhow::Result<(Cow<OsString>, Cow<str>)> {
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."));
};
#[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),
}

// e.g. uv, no special handling
let Some((name, version)) = target_str.split_once('@') else {
return Ok((Cow::Borrowed(target), Cow::Borrowed(target_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."));
};

// e.g. `uv@`, warn and treat the whole thing as the command
if version.is_empty() {
debug!("Ignoring empty version request in command");
return Ok((Cow::Borrowed(target), Cow::Borrowed(target_str)));
}
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/uv/uv.git@main`
if PackageName::from_str(name).is_err() {
debug!("Ignoring non-package name `{name}` in command");
return Ok((Cow::Borrowed(target), Cow::Borrowed(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))
}

// e.g. `uv@0.1.0`, convert to `uv==0.1.0`
if let Ok(version) = Version::from_str(version) {
return Ok((
Cow::Owned(OsString::from(name)),
Cow::Owned(format!("{name}=={version}")),
));
/// 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,
}
}

// e.g. `uv@invalid`, warn and treat the whole thing as the command
debug!("Ignoring invalid version request `{version}` in command");
Ok((Cow::Borrowed(target), Cow::Borrowed(target_str)))
/// 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
/// [`PythonEnvironment`]. Otherwise, gets or creates a [`CachedEnvironment`].
async fn get_or_create_environment(
from: &str,
target: &Target<'_>,
with: &[RequirementsSource],
show_resolution: bool,
python: Option<&str>,
Expand Down Expand Up @@ -374,9 +413,45 @@ async fn get_or_create_environment(
// Initialize any shared state.
let state = SharedState::default();

// Resolve the `from` requirement.
let from = {
resolve_names(
// Resolve the `--from` requirement.
let from = match target {
// Ex) `ruff`
Target::Unspecified(name) => 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::Version(name, version) => 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) => 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(_, from) => resolve_names(
vec![RequirementsSpecification::parse_package(from)?],
&interpreter,
settings,
Expand All @@ -390,7 +465,7 @@ async fn get_or_create_environment(
)
.await?
.pop()
.unwrap()
.unwrap(),
};

// Read the `--with` requirements.
Expand Down Expand Up @@ -424,7 +499,11 @@ async fn get_or_create_environment(
};

// Check if the tool is already installed in a compatible environment.
if !isolated && settings.reinstall.is_none() && settings.upgrade.is_none() {
if !isolated
&& !target.is_latest()
&& settings.reinstall.is_none()
&& settings.upgrade.is_none()
{
let installed_tools = InstalledTools::from_settings()?.init()?;
let _lock = installed_tools.acquire_lock()?;

Expand Down
2 changes: 1 addition & 1 deletion crates/uv/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -774,7 +774,7 @@ async fn run(cli: Cli) -> Result<ExitStatus> {
globals.connectivity,
Concurrency::default(),
globals.native_tls,
&cache,
cache,
printer,
)
.await
Expand Down
Loading

0 comments on commit c80a831

Please sign in to comment.