From 492e778fe7e90fb95a81c0d8a38a68c2b7aa7fe7 Mon Sep 17 00:00:00 2001 From: Ahmed Ilyas Date: Mon, 15 Jul 2024 21:27:10 +0200 Subject: [PATCH] `uvx` warn when executable is not part of `--from PKG` (#5071) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Resolves #5017. Note: This re-uses the same function defined in #5019 to find matching packages. ## Test Plan `cargo test` ```console ❯ ./target/debug/uvx --from fastapi fastapi warning: `uvx` is experimental and may change without warning. Resolved 33 packages in 427ms warning: The fastapi executable is not part of the fastapi package. It is provided by the fastapi-cli package. Use `uvx --from fastapi-cli fastapi` instead. Usage: fastapi [OPTIONS] COMMAND [ARGS]... Try 'fastapi --help' for help. ``` --------- Co-authored-by: Zanie Blue --- crates/uv/src/commands/tool/common.rs | 4 +- crates/uv/src/commands/tool/run.rs | 132 ++++++++++++++++++-------- crates/uv/tests/tool_run.rs | 78 ++++++++++++++- 3 files changed, 168 insertions(+), 46 deletions(-) diff --git a/crates/uv/src/commands/tool/common.rs b/crates/uv/src/commands/tool/common.rs index 3e99f3271..e91d32df9 100644 --- a/crates/uv/src/commands/tool/common.rs +++ b/crates/uv/src/commands/tool/common.rs @@ -56,7 +56,7 @@ pub(super) fn matching_packages( environment: &PythonEnvironment, ) -> anyhow::Result> { let site_packages = SitePackages::from_environment(environment)?; - let entrypoints = site_packages + let packages = site_packages .iter() .filter_map(|package| { entrypoint_paths(environment, package.name(), package.version()) @@ -75,5 +75,5 @@ pub(super) fn matching_packages( }) .collect(); - Ok(entrypoints) + Ok(packages) } diff --git a/crates/uv/src/commands/tool/run.rs b/crates/uv/src/commands/tool/run.rs index c402cba33..5cfb70963 100644 --- a/crates/uv/src/commands/tool/run.rs +++ b/crates/uv/src/commands/tool/run.rs @@ -7,6 +7,7 @@ use std::{borrow::Cow, fmt::Display}; use anyhow::{bail, Context, Result}; use itertools::Itertools; use owo_colors::OwoColorize; +use pypi_types::Requirement; use tokio::process::Command; use tracing::{debug, warn}; @@ -23,11 +24,11 @@ use uv_python::{ PythonRequest, }; use uv_tool::{entrypoint_paths, InstalledTools}; -use uv_warnings::warn_user_once; +use uv_warnings::{warn_user, warn_user_once}; -use crate::commands::project::environment::CachedEnvironment; use crate::commands::reporters::PythonDownloadReporter; use crate::commands::tool::common::resolve_requirements; +use crate::commands::{project::environment::CachedEnvironment, tool::common::matching_packages}; use crate::commands::{ExitStatus, SharedState}; use crate::printer::Printer; use crate::settings::ResolverInstallerSettings; @@ -71,8 +72,6 @@ pub(crate) async fn run( warn_user_once!("`{invocation_source}` 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")); @@ -85,7 +84,7 @@ pub(crate) async fn run( }; // Get or create a compatible environment in which to execute the tool. - let environment = get_or_create_environment( + let (from, environment) = get_or_create_environment( &from, &with, python.as_deref(), @@ -101,11 +100,12 @@ pub(crate) async fn run( printer, ) .await?; - // TODO(zanieb): Determine the command via the package entry points - let command = target; + + // TODO(zanieb): Determine the executable command via the package entry points + let executable = target; // Construct the command - let mut process = Command::new(command.as_ref()); + let mut process = Command::new(executable.as_ref()); process.args(args); // Construct the `PATH` environment variable. @@ -136,39 +136,35 @@ pub(crate) async fn run( let space = if args.is_empty() { "" } else { " " }; debug!( "Running `{}{space}{}`", - command.to_string_lossy(), + executable.to_string_lossy(), args.iter().map(|arg| arg.to_string_lossy()).join(" ") ); + + // 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(), + &from.name, + &environment, + &invocation_source, + ); + let mut handle = match process.spawn() { Ok(handle) => Ok(handle), Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - match get_entrypoints(&from, &environment) { + match get_entrypoints(&from.name, &environment) { Ok(entrypoints) => { - if entrypoints.is_empty() { + writeln!( + printer.stdout(), + "The executable `{}` was not found.", + executable.to_string_lossy().red(), + )?; + if !entrypoints.is_empty() { writeln!( printer.stdout(), - "The executable {} was not found.", - command.to_string_lossy().red(), + "The following executables are provided by `{}`:", + &from.name.green() )?; - } 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:", - )?; - } else { - let command = format!("{invocation_source} --from {from} "); - writeln!( - printer.stdout(), - "However, the following executables are available via {}:", - command.green(), - )?; - } for (name, _) in entrypoints { writeln!(printer.stdout(), "- {}", name.cyan())?; } @@ -183,7 +179,7 @@ pub(crate) async fn run( } Err(err) => Err(err), } - .with_context(|| format!("Failed to spawn: `{}`", command.to_string_lossy()))?; + .with_context(|| format!("Failed to spawn: `{}`", executable.to_string_lossy()))?; let status = handle.wait().await.context("Child process disappeared")?; @@ -197,11 +193,13 @@ pub(crate) async fn run( } /// Return the entry points for the specified package. -fn get_entrypoints(from: &str, environment: &PythonEnvironment) -> Result> { +fn get_entrypoints( + from: &PackageName, + environment: &PythonEnvironment, +) -> Result> { let site_packages = SitePackages::from_environment(environment)?; - let package = PackageName::from_str(from)?; - let installed = site_packages.get_packages(&package); + let installed = site_packages.get_packages(from); let Some(installed_dist) = installed.first().copied() else { bail!("Expected at least one requirement") }; @@ -213,6 +211,62 @@ fn get_entrypoints(from: &str, environment: &PythonEnvironment) -> Result { + warn_user!( + "A `{}` executable is not provided by package `{}`.", + executable.green(), + from_package.red() + ); + } + [package] => { + let suggested_command = format!( + "{invocation_source} --from {} {}", + package.name(), + executable + ); + warn_user!( + "A `{}` executable is not provided by package `{}` but is available via the dependency `{}`. Consider using `{}` instead.", + executable.green(), + from_package.red(), + package.name().green(), + suggested_command.cyan() + ); + } + packages => { + let suggested_command = format!("{invocation_source} --from PKG {executable}"); + let provided_by = packages + .iter() + .map(distribution_types::Name::name) + .map(|name| format!("- {}", name.cyan())) + .join("\n"); + warn_user!( + "A `{}` executable is not provided by package `{}` but is available via the following dependencies:\n- {}\nConsider using `{}` instead.", + executable.green(), + from_package.red(), + provided_by, + suggested_command.cyan(), + ); + } + } + } + } +} + /// 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 @@ -231,7 +285,7 @@ async fn get_or_create_environment( native_tls: bool, cache: &Cache, printer: Printer, -) -> Result { +) -> Result<(Requirement, PythonEnvironment)> { let client_builder = BaseClientBuilder::new() .connectivity(connectivity) .native_tls(native_tls); @@ -326,7 +380,7 @@ async fn get_or_create_environment( Ok(SatisfiesResult::Fresh { .. }) ) { debug!("Using existing tool `{}`", from.name); - return Ok(environment); + return Ok((from, environment)); } } } @@ -348,7 +402,7 @@ async fn get_or_create_environment( ) .await?; - Ok(environment.into()) + Ok((from, environment.into())) } /// Parse a target into a command name and a requirement. diff --git a/crates/uv/tests/tool_run.rs b/crates/uv/tests/tool_run.rs index 9e38bfafa..9afcf974d 100644 --- a/crates/uv/tests/tool_run.rs +++ b/crates/uv/tests/tool_run.rs @@ -148,8 +148,8 @@ fn tool_run_at_version() { success: false exit_code: 1 ----- stdout ----- - The executable pytest@8.0.0 was not found. - However, the following executables are available: + The executable `pytest@8.0.0` was not found. + The following executables are provided by `pytest`: - py.test - pytest @@ -162,6 +162,7 @@ fn tool_run_at_version() { + packaging==24.0 + pluggy==1.4.0 + pytest==8.1.1 + warning: A `pytest@8.0.0` executable is not provided by package `pytest`. "###); } @@ -210,8 +211,8 @@ fn tool_run_suggest_valid_commands() { success: false exit_code: 1 ----- stdout ----- - The executable orange was not found. - However, the following executables are available: + The executable `orange` was not found. + The following executables are provided by `black`: - black - blackd @@ -226,6 +227,7 @@ fn tool_run_suggest_valid_commands() { + packaging==24.0 + pathspec==0.12.1 + platformdirs==4.2.0 + warning: A `orange` executable is not provided by package `black`. "###); uv_snapshot!(context.filters(), context.tool_run() @@ -235,7 +237,7 @@ fn tool_run_suggest_valid_commands() { success: false exit_code: 1 ----- stdout ----- - The executable fastapi-cli was not found. + The executable `fastapi-cli` was not found. ----- stderr ----- warning: `uv tool run` is experimental and may change without warning. @@ -245,6 +247,72 @@ fn tool_run_suggest_valid_commands() { + fastapi-cli==0.0.1 + importlib-metadata==1.7.0 + zipp==3.18.1 + warning: A `fastapi-cli` executable is not provided by package `fastapi-cli`. + "###); +} + +#[test] +fn tool_run_warn_executable_not_in_from() { + 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"); + let mut filters = context.filters(); + filters.push(("\\+ uvloop(.+)\n ", "")); + // Strip off the `fastapi` command output. + filters.push(("(?s)fastapi` instead.*", "fastapi` instead.")); + + uv_snapshot!(filters, context.tool_run() + .arg("--from") + .arg("fastapi") + .arg("fastapi") + .env("UV_EXCLUDE_NEWER", "2024-05-04T00:00:00Z") // TODO: Remove this once EXCLUDE_NEWER is bumped past 2024-05-04 + // (FastAPI 0.111 is only available from this date onwards) + .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 ----- + + ----- stderr ----- + warning: `uv tool run` is experimental and may change without warning. + Resolved 35 packages in [TIME] + Prepared 35 packages in [TIME] + Installed 35 packages in [TIME] + + annotated-types==0.6.0 + + anyio==4.3.0 + + certifi==2024.2.2 + + click==8.1.7 + + dnspython==2.6.1 + + email-validator==2.1.1 + + fastapi==0.111.0 + + fastapi-cli==0.0.2 + + h11==0.14.0 + + httpcore==1.0.5 + + httptools==0.6.1 + + httpx==0.27.0 + + idna==3.7 + + jinja2==3.1.3 + + markdown-it-py==3.0.0 + + markupsafe==2.1.5 + + mdurl==0.1.2 + + orjson==3.10.3 + + pydantic==2.7.1 + + pydantic-core==2.18.2 + + pygments==2.17.2 + + python-dotenv==1.0.1 + + python-multipart==0.0.9 + + pyyaml==6.0.1 + + rich==13.7.1 + + shellingham==1.5.4 + + sniffio==1.3.1 + + starlette==0.37.2 + + typer==0.12.3 + + typing-extensions==4.11.0 + + ujson==5.9.0 + + uvicorn==0.29.0 + + watchfiles==0.21.0 + + websockets==12.0 + warning: A `fastapi` executable is not provided by package `fastapi` but is available via the dependency `fastapi-cli`. Consider using `uv tool run --from fastapi-cli fastapi` instead. "###); }