diff --git a/crates/uv/src/commands/tool/run.rs b/crates/uv/src/commands/tool/run.rs index e1af04dd3..e13160261 100644 --- a/crates/uv/src/commands/tool/run.rs +++ b/crates/uv/src/commands/tool/run.rs @@ -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 { + /// 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. diff --git a/crates/uv/tests/it/tool_run.rs b/crates/uv/tests/it/tool_run.rs index 919393c39..d0beb79c8 100644 --- a/crates/uv/tests/it/tool_run.rs +++ b/crates/uv/tests/it/tool_run.rs @@ -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` + "); +}