mirror of
https://github.com/astral-sh/uv.git
synced 2025-07-07 13:25:00 +00:00
uvx
warn when executable is not part of --from PKG
(#5071)
## 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 <contact@zanie.dev>
This commit is contained in:
parent
8c0ad5b75e
commit
492e778fe7
3 changed files with 168 additions and 46 deletions
|
@ -56,7 +56,7 @@ pub(super) fn matching_packages(
|
|||
environment: &PythonEnvironment,
|
||||
) -> anyhow::Result<Vec<InstalledDist>> {
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -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} <EXECUTABLE>");
|
||||
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<Vec<(String, PathBuf)>> {
|
||||
fn get_entrypoints(
|
||||
from: &PackageName,
|
||||
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 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<Vec<(S
|
|||
)?)
|
||||
}
|
||||
|
||||
/// Display a warning if an executable is not provided by package.
|
||||
///
|
||||
/// If found in a dependency of the requested package instead of the requested package itself, we will hint to use that instead.
|
||||
fn warn_executable_not_provided_by_package(
|
||||
executable: &str,
|
||||
from_package: &PackageName,
|
||||
environment: &PythonEnvironment,
|
||||
invocation_source: &ToolRunCommand,
|
||||
) {
|
||||
if let Ok(packages) = matching_packages(executable, environment) {
|
||||
if !packages
|
||||
.iter()
|
||||
.any(|package| package.name() == from_package)
|
||||
{
|
||||
match packages.as_slice() {
|
||||
[] => {
|
||||
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<PythonEnvironment> {
|
||||
) -> 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.
|
||||
|
|
|
@ -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.
|
||||
"###);
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue