Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

uv tool run suggest valid commands when command is not found #4997

Merged
merged 8 commits into from
Jul 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 71 additions & 8 deletions crates/uv/src/commands/tool/run.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
use std::borrow::Cow;
use std::ffi::OsString;
use std::fmt::Write;
use std::path::PathBuf;
use std::str::FromStr;

use anyhow::{Context, Result};
use anyhow::{bail, Context, Result};
use itertools::Itertools;
use owo_colors::OwoColorize;
use tokio::process::Command;
use tracing::debug;
use tracing::{debug, warn};

use distribution_types::UnresolvedRequirementSpecification;
use distribution_types::{Name, UnresolvedRequirementSpecification};
use pep440_rs::Version;
use uv_cache::Cache;
use uv_cli::ExternalCommand;
Expand All @@ -20,7 +22,7 @@ use uv_python::{
EnvironmentPreference, PythonEnvironment, PythonFetch, PythonInstallation, PythonPreference,
PythonRequest,
};
use uv_tool::InstalledTools;
use uv_tool::{entrypoint_paths, InstalledTools};
use uv_warnings::warn_user_once;

use crate::commands::project::environment::CachedEnvironment;
Expand Down Expand Up @@ -51,6 +53,8 @@ pub(crate) async fn run(
warn_user_once!("`uv tool run` is experimental and may change without warning.");
}

let has_from = from.is_some();

let (target, args) = command.split();
let Some(target) = target else {
return Err(anyhow::anyhow!("No tool command provided"));
Expand Down Expand Up @@ -79,7 +83,6 @@ pub(crate) async fn run(
printer,
)
.await?;

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

Expand Down Expand Up @@ -118,9 +121,52 @@ pub(crate) async fn run(
command.to_string_lossy(),
args.iter().map(|arg| arg.to_string_lossy()).join(" ")
);
let mut handle = process
.spawn()
.with_context(|| format!("Failed to spawn: `{}`", command.to_string_lossy()))?;
let mut handle = match process.spawn() {
Ok(handle) => Ok(handle),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
match get_entrypoints(&from, &environment) {
Ok(entrypoints) => {
if entrypoints.is_empty() {
writeln!(
printer.stdout(),
"The executable {} was not found.",
command.to_string_lossy().red(),
)?;
} else {
writeln!(
printer.stdout(),
"The executable {} was not found.",
command.to_string_lossy().red()
)?;
if has_from {
writeln!(
printer.stdout(),
"However, the following executables are available:",
Copy link
Contributor

@j178 j178 Jul 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are executables from sub-depencencies considered as available? For instance, uv tool run --from fastapi, uvicorn is also an available executable, but it is not listed here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we'll want a separate flag to opt-in to that, per #4994

Maybe we should even warn or error if someone uses an executable that's not provided by the --from package?

)?;
} else {
let command = format!("uv tool run --from {from} <EXECUTABLE>");
writeln!(
printer.stdout(),
"However, the following executables are available via {}:",
command.green(),
)?;
}
for (name, _) in entrypoints {
writeln!(printer.stdout(), "- {}", name.cyan())?;
}
}
return Ok(ExitStatus::Failure);
}
Err(err) => {
warn!("Failed to get entrypoints for `{from}`: {err}");
}
}
Err(err)
}
Err(err) => Err(err),
}
.with_context(|| format!("Failed to spawn: `{}`", command.to_string_lossy()))?;

let status = handle.wait().await.context("Child process disappeared")?;

// Exit based on the result of the command
Expand All @@ -132,6 +178,23 @@ pub(crate) async fn run(
}
}

/// Return the entry points for the specified package.
fn get_entrypoints(from: &str, environment: &PythonEnvironment) -> Result<Vec<(String, PathBuf)>> {
let site_packages = SitePackages::from_environment(environment)?;
let package = PackageName::from_str(from)?;

let installed = site_packages.get_packages(&package);
let Some(installed_dist) = installed.first().copied() else {
bail!("Expected at least one requirement")
};

Ok(entrypoint_paths(
environment,
installed_dist.name(),
installed_dist.version(),
)?)
}

/// 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
Expand Down
75 changes: 65 additions & 10 deletions crates/uv/tests/tool_run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ fn tool_run_args() {

#[test]
fn tool_run_at_version() {
let context = TestContext::new("3.12");
let context = TestContext::new("3.12").with_filtered_exe_suffix();
let tool_dir = context.temp_dir.child("tools");
let bin_dir = context.temp_dir.child("bin");

Expand Down Expand Up @@ -139,15 +139,19 @@ fn tool_run_at_version() {

// When `--from` is used, `@` is not treated as a version request
uv_snapshot!(filters, context.tool_run()
.arg("--from")
.arg("pytest")
.arg("[email protected]")
.arg("--version")
.env("UV_TOOL_DIR", tool_dir.as_os_str())
.env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###"
.arg("--from")
.arg("pytest")
.arg("[email protected]")
.arg("--version")
.env("UV_TOOL_DIR", tool_dir.as_os_str())
.env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###"
success: false
exit_code: 2
exit_code: 1
----- stdout -----
The executable [email protected] was not found.
However, the following executables are available:
- py.test
- pytest

----- stderr -----
warning: `uv tool run` is experimental and may change without warning.
Expand All @@ -158,8 +162,6 @@ fn tool_run_at_version() {
+ packaging==24.0
+ pluggy==1.4.0
+ pytest==8.1.1
error: Failed to spawn: `[email protected]`
Caused by: No such file or directory (os error 2)
"###);
}

Expand Down Expand Up @@ -193,6 +195,59 @@ fn tool_run_from_version() {
"###);
}

#[test]
fn tool_run_suggest_valid_commands() {
let context = TestContext::new("3.12").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_run()
.arg("--from")
.arg("black")
.arg("orange")
.env("UV_TOOL_DIR", tool_dir.as_os_str())
.env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###"
success: false
exit_code: 1
----- stdout -----
The executable orange was not found.
However, the following executables are available:
- black
- blackd

----- stderr -----
warning: `uv tool run` is experimental and may change without warning.
Resolved 6 packages in [TIME]
Prepared 6 packages in [TIME]
Installed 6 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
"###);

uv_snapshot!(context.filters(), context.tool_run()
.arg("fastapi-cli")
.env("UV_TOOL_DIR", tool_dir.as_os_str())
.env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###"
success: false
exit_code: 1
----- stdout -----
The executable fastapi-cli was not found.

----- stderr -----
warning: `uv tool run` is experimental and may change without warning.
Resolved 3 packages in [TIME]
Prepared 3 packages in [TIME]
Installed 3 packages in [TIME]
+ fastapi-cli==0.0.1
+ importlib-metadata==1.7.0
+ zipp==3.18.1
"###);
}

#[test]
fn tool_run_from_install() {
let context = TestContext::new("3.12");
Expand Down
Loading