Return error when running uvx with a .py script (#11623)

If we see `uvx script.py`, we exit early, giving a hint to use `uv run
script.py` if the script exists. If it does not exist, we suggest
running `uv run` with a normalized package name.

This PR includes a snapshot test for each of these scenarios.

An alternative approach would be to wait until we encounter an error,
and then add the hint. But if there happens to be a malicious package
called `script-py`, this would be run unintentionally (a point raised by
@zanieb).
 
Closes #10784

---------

Co-authored-by: Zanie Blue <contact@zanie.dev>
This commit is contained in:
John Mumm 2025-03-04 15:29:36 +01:00 committed by GitHub
parent 3b83b48fd2
commit c072c9adca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 161 additions and 2 deletions

View file

@ -1,5 +1,6 @@
use std::fmt::Display;
use std::fmt::Write;
use std::path::Path;
use std::path::PathBuf;
use std::str::FromStr;
@ -18,6 +19,7 @@ use uv_configuration::{Concurrency, PreviewMode};
use uv_distribution_types::{
Name, NameRequirementSpecification, UnresolvedRequirement, UnresolvedRequirementSpecification,
};
use uv_fs::Simplified;
use uv_installer::{SatisfiesResult, SitePackages};
use uv_normalize::PackageName;
use uv_pep440::{VersionSpecifier, VersionSpecifiers};
@ -92,6 +94,12 @@ pub(crate) async fn run(
printer: Printer,
preview: PreviewMode,
) -> anyhow::Result<ExitStatus> {
/// Whether or not a path looks like a Python script based on the file extension.
fn has_python_script_ext(path: &Path) -> bool {
path.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("py") || ext.eq_ignore_ascii_case("pyw"))
}
let Some(command) = command else {
// When a command isn't provided, we'll show a brief help including available tools
show_help(invocation_source, &cache, printer).await?;
@ -105,9 +113,46 @@ pub(crate) async fn run(
};
let Some(target) = 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."));
return Err(anyhow::anyhow!("Tool command could not be parsed as UTF-8 string. Use `--from` to specify the package name"));
};
if let Some(ref from) = from {
if has_python_script_ext(Path::new(from)) {
let package_name = PackageName::from_str(from)?;
return Err(anyhow::anyhow!(
"It looks you provided a Python script to `--from`, which is not supported\n\n{}{} If you meant to run a command from the `{}` package, use the normalized package name instead to disambiguate, e.g., `{}`",
"hint".bold().cyan(),
":".bold(),
package_name.cyan(),
format!("{} --from {} {}", invocation_source, package_name.cyan(), target),
));
}
} else {
let target_path = Path::new(target);
if has_python_script_ext(target_path) {
return if target_path.try_exists()? {
Err(anyhow::anyhow!(
"It looks you tried to run a Python script at `{}`, which is not supported by `{}`\n\n{}{} Use `{}` instead",
target_path.user_display(),
invocation_source,
"hint".bold().cyan(),
":".bold(),
format!("uv run {}", target_path.user_display().cyan()),
))
} else {
let package_name = PackageName::from_str(target)?;
Err(anyhow::anyhow!(
"It looks you provided a Python script to run, which is not supported supported by `{}`\n\n{}{} We did not find a script at the requested path. If you meant to run a command from the `{}` package, pass the normalized package name to `--from` to disambiguate, e.g., `{}`",
invocation_source,
"hint".bold().cyan(),
":".bold(),
package_name.cyan(),
format!("{} --from {} {}", invocation_source, package_name, target),
))
};
}
}
let request = ToolRequest::parse(target, from.as_deref());
// If the user passed, e.g., `ruff@latest`, refresh the cache.

View file

@ -1760,7 +1760,7 @@ fn tool_run_python_at_version() {
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Invalid version request: 3.300
"###);
@ -1962,3 +1962,117 @@ fn tool_run_verbatim_name() {
Resolved [N] packages in [TIME]
");
}
#[test]
fn tool_run_with_existing_py_script() -> anyhow::Result<()> {
let context = TestContext::new("3.12").with_filtered_counts();
context.temp_dir.child("script.py").touch()?;
uv_snapshot!(context.filters(), context.tool_run().arg("script.py"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: It looks you tried to run a Python script at `script.py`, which is not supported by `uv tool run`
hint: Use `uv run script.py` instead
");
Ok(())
}
#[test]
fn tool_run_with_existing_pyw_script() -> anyhow::Result<()> {
let context = TestContext::new("3.12").with_filtered_counts();
context.temp_dir.child("script.pyw").touch()?;
// We treat arguments before the command as uv arguments
uv_snapshot!(context.filters(), context.tool_run()
.arg("script.pyw"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: It looks you tried to run a Python script at `script.pyw`, which is not supported by `uv tool run`
hint: Use `uv run script.pyw` instead
");
Ok(())
}
#[test]
fn tool_run_with_nonexistent_py_script() {
let context = TestContext::new("3.12").with_filtered_counts();
// We treat arguments before the command as uv arguments
uv_snapshot!(context.filters(), context.tool_run()
.arg("script.py"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: It looks you provided a Python script to run, which is not supported supported by `uv tool run`
hint: We did not find a script at the requested path. If you meant to run a command from the `script-py` package, pass the normalized package name to `--from` to disambiguate, e.g., `uv tool run --from script-py script.py`
");
}
#[test]
fn tool_run_with_nonexistent_pyw_script() {
let context = TestContext::new("3.12").with_filtered_counts();
// We treat arguments before the command as uv arguments
uv_snapshot!(context.filters(), context.tool_run()
.arg("script.pyw"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: It looks you provided a Python script to run, which is not supported supported by `uv tool run`
hint: We did not find a script at the requested path. If you meant to run a command from the `script-pyw` package, pass the normalized package name to `--from` to disambiguate, e.g., `uv tool run --from script-pyw script.pyw`
");
}
#[test]
fn tool_run_with_from_script() {
let context = TestContext::new("3.12").with_filtered_counts();
// We treat arguments before the command as uv arguments
uv_snapshot!(context.filters(), context.tool_run()
.arg("--from")
.arg("script.py")
.arg("ruff"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: It looks you provided a Python script to `--from`, which is not supported
hint: If you meant to run a command from the `script-py` package, use the normalized package name instead to disambiguate, e.g., `uv tool run --from script-py ruff`
");
}
#[test]
fn tool_run_with_script_and_from_script() {
let context = TestContext::new("3.12").with_filtered_counts();
// We treat arguments before the command as uv arguments
uv_snapshot!(context.filters(), context.tool_run()
.arg("--from")
.arg("script.py")
.arg("other-script.py"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: It looks you provided a Python script to `--from`, which is not supported
hint: If you meant to run a command from the `script-py` package, use the normalized package name instead to disambiguate, e.g., `uv tool run --from script-py other-script.py`
");
}