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,
|
environment: &PythonEnvironment,
|
||||||
) -> anyhow::Result<Vec<InstalledDist>> {
|
) -> anyhow::Result<Vec<InstalledDist>> {
|
||||||
let site_packages = SitePackages::from_environment(environment)?;
|
let site_packages = SitePackages::from_environment(environment)?;
|
||||||
let entrypoints = site_packages
|
let packages = site_packages
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|package| {
|
.filter_map(|package| {
|
||||||
entrypoint_paths(environment, package.name(), package.version())
|
entrypoint_paths(environment, package.name(), package.version())
|
||||||
|
@ -75,5 +75,5 @@ pub(super) fn matching_packages(
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(entrypoints)
|
Ok(packages)
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ use std::{borrow::Cow, fmt::Display};
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use owo_colors::OwoColorize;
|
use owo_colors::OwoColorize;
|
||||||
|
use pypi_types::Requirement;
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
|
@ -23,11 +24,11 @@ use uv_python::{
|
||||||
PythonRequest,
|
PythonRequest,
|
||||||
};
|
};
|
||||||
use uv_tool::{entrypoint_paths, InstalledTools};
|
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::reporters::PythonDownloadReporter;
|
||||||
use crate::commands::tool::common::resolve_requirements;
|
use crate::commands::tool::common::resolve_requirements;
|
||||||
|
use crate::commands::{project::environment::CachedEnvironment, tool::common::matching_packages};
|
||||||
use crate::commands::{ExitStatus, SharedState};
|
use crate::commands::{ExitStatus, SharedState};
|
||||||
use crate::printer::Printer;
|
use crate::printer::Printer;
|
||||||
use crate::settings::ResolverInstallerSettings;
|
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.");
|
warn_user_once!("`{invocation_source}` is experimental and may change without warning.");
|
||||||
}
|
}
|
||||||
|
|
||||||
let has_from = from.is_some();
|
|
||||||
|
|
||||||
let (target, args) = command.split();
|
let (target, args) = command.split();
|
||||||
let Some(target) = target else {
|
let Some(target) = target else {
|
||||||
return Err(anyhow::anyhow!("No tool command provided"));
|
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.
|
// 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,
|
&from,
|
||||||
&with,
|
&with,
|
||||||
python.as_deref(),
|
python.as_deref(),
|
||||||
|
@ -101,11 +100,12 @@ pub(crate) async fn run(
|
||||||
printer,
|
printer,
|
||||||
)
|
)
|
||||||
.await?;
|
.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
|
// Construct the command
|
||||||
let mut process = Command::new(command.as_ref());
|
let mut process = Command::new(executable.as_ref());
|
||||||
process.args(args);
|
process.args(args);
|
||||||
|
|
||||||
// Construct the `PATH` environment variable.
|
// Construct the `PATH` environment variable.
|
||||||
|
@ -136,39 +136,35 @@ pub(crate) async fn run(
|
||||||
let space = if args.is_empty() { "" } else { " " };
|
let space = if args.is_empty() { "" } else { " " };
|
||||||
debug!(
|
debug!(
|
||||||
"Running `{}{space}{}`",
|
"Running `{}{space}{}`",
|
||||||
command.to_string_lossy(),
|
executable.to_string_lossy(),
|
||||||
args.iter().map(|arg| arg.to_string_lossy()).join(" ")
|
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() {
|
let mut handle = match process.spawn() {
|
||||||
Ok(handle) => Ok(handle),
|
Ok(handle) => Ok(handle),
|
||||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||||
match get_entrypoints(&from, &environment) {
|
match get_entrypoints(&from.name, &environment) {
|
||||||
Ok(entrypoints) => {
|
Ok(entrypoints) => {
|
||||||
if entrypoints.is_empty() {
|
writeln!(
|
||||||
|
printer.stdout(),
|
||||||
|
"The executable `{}` was not found.",
|
||||||
|
executable.to_string_lossy().red(),
|
||||||
|
)?;
|
||||||
|
if !entrypoints.is_empty() {
|
||||||
writeln!(
|
writeln!(
|
||||||
printer.stdout(),
|
printer.stdout(),
|
||||||
"The executable {} was not found.",
|
"The following executables are provided by `{}`:",
|
||||||
command.to_string_lossy().red(),
|
&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 {
|
for (name, _) in entrypoints {
|
||||||
writeln!(printer.stdout(), "- {}", name.cyan())?;
|
writeln!(printer.stdout(), "- {}", name.cyan())?;
|
||||||
}
|
}
|
||||||
|
@ -183,7 +179,7 @@ pub(crate) async fn run(
|
||||||
}
|
}
|
||||||
Err(err) => Err(err),
|
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")?;
|
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.
|
/// 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 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 {
|
let Some(installed_dist) = installed.first().copied() else {
|
||||||
bail!("Expected at least one requirement")
|
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.
|
/// 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
|
/// 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,
|
native_tls: bool,
|
||||||
cache: &Cache,
|
cache: &Cache,
|
||||||
printer: Printer,
|
printer: Printer,
|
||||||
) -> Result<PythonEnvironment> {
|
) -> Result<(Requirement, PythonEnvironment)> {
|
||||||
let client_builder = BaseClientBuilder::new()
|
let client_builder = BaseClientBuilder::new()
|
||||||
.connectivity(connectivity)
|
.connectivity(connectivity)
|
||||||
.native_tls(native_tls);
|
.native_tls(native_tls);
|
||||||
|
@ -326,7 +380,7 @@ async fn get_or_create_environment(
|
||||||
Ok(SatisfiesResult::Fresh { .. })
|
Ok(SatisfiesResult::Fresh { .. })
|
||||||
) {
|
) {
|
||||||
debug!("Using existing tool `{}`", from.name);
|
debug!("Using existing tool `{}`", from.name);
|
||||||
return Ok(environment);
|
return Ok((from, environment));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -348,7 +402,7 @@ async fn get_or_create_environment(
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(environment.into())
|
Ok((from, environment.into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a target into a command name and a requirement.
|
/// Parse a target into a command name and a requirement.
|
||||||
|
|
|
@ -148,8 +148,8 @@ fn tool_run_at_version() {
|
||||||
success: false
|
success: false
|
||||||
exit_code: 1
|
exit_code: 1
|
||||||
----- stdout -----
|
----- stdout -----
|
||||||
The executable pytest@8.0.0 was not found.
|
The executable `pytest@8.0.0` was not found.
|
||||||
However, the following executables are available:
|
The following executables are provided by `pytest`:
|
||||||
- py.test
|
- py.test
|
||||||
- pytest
|
- pytest
|
||||||
|
|
||||||
|
@ -162,6 +162,7 @@ fn tool_run_at_version() {
|
||||||
+ packaging==24.0
|
+ packaging==24.0
|
||||||
+ pluggy==1.4.0
|
+ pluggy==1.4.0
|
||||||
+ pytest==8.1.1
|
+ 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
|
success: false
|
||||||
exit_code: 1
|
exit_code: 1
|
||||||
----- stdout -----
|
----- stdout -----
|
||||||
The executable orange was not found.
|
The executable `orange` was not found.
|
||||||
However, the following executables are available:
|
The following executables are provided by `black`:
|
||||||
- black
|
- black
|
||||||
- blackd
|
- blackd
|
||||||
|
|
||||||
|
@ -226,6 +227,7 @@ fn tool_run_suggest_valid_commands() {
|
||||||
+ packaging==24.0
|
+ packaging==24.0
|
||||||
+ pathspec==0.12.1
|
+ pathspec==0.12.1
|
||||||
+ platformdirs==4.2.0
|
+ platformdirs==4.2.0
|
||||||
|
warning: A `orange` executable is not provided by package `black`.
|
||||||
"###);
|
"###);
|
||||||
|
|
||||||
uv_snapshot!(context.filters(), context.tool_run()
|
uv_snapshot!(context.filters(), context.tool_run()
|
||||||
|
@ -235,7 +237,7 @@ fn tool_run_suggest_valid_commands() {
|
||||||
success: false
|
success: false
|
||||||
exit_code: 1
|
exit_code: 1
|
||||||
----- stdout -----
|
----- stdout -----
|
||||||
The executable fastapi-cli was not found.
|
The executable `fastapi-cli` was not found.
|
||||||
|
|
||||||
----- stderr -----
|
----- stderr -----
|
||||||
warning: `uv tool run` is experimental and may change without warning.
|
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
|
+ fastapi-cli==0.0.1
|
||||||
+ importlib-metadata==1.7.0
|
+ importlib-metadata==1.7.0
|
||||||
+ zipp==3.18.1
|
+ 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