From 4414f6b51afc07848514be9028c7a8b34d0267d6 Mon Sep 17 00:00:00 2001 From: Jay Lee Date: Sat, 15 Mar 2025 22:13:57 +0900 Subject: [PATCH 1/7] feat: add function for checking if command is python executable --- crates/uv/src/commands/project/run.rs | 82 ++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 3 deletions(-) diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 05be2ca09faf..44fc9eab4ffe 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -4,6 +4,7 @@ use std::ffi::OsString; use std::fmt::Write; use std::io::Read; use std::path::{Path, PathBuf}; +use std::str::FromStr; use anyhow::{anyhow, bail, Context}; use futures::StreamExt; @@ -25,9 +26,7 @@ use uv_fs::{PythonExt, Simplified}; use uv_installer::{SatisfiesResult, SitePackages}; use uv_normalize::PackageName; use uv_python::{ - EnvironmentPreference, Interpreter, PyVenvConfiguration, PythonDownloads, PythonEnvironment, - PythonInstallation, PythonPreference, PythonRequest, PythonVersionFile, - VersionFileDiscoveryOptions, + EnvironmentPreference, Interpreter, PyVenvConfiguration, PythonDownloads, PythonEnvironment, PythonInstallation, PythonPreference, PythonRequest, PythonVersion, PythonVersionFile, VersionFileDiscoveryOptions }; use uv_requirements::{RequirementsSource, RequirementsSpecification}; use uv_resolver::Lock; @@ -1070,6 +1069,9 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl process.env(EnvVars::VIRTUAL_ENV, interpreter.sys_prefix().as_os_str()); }; + let is_executable = is_python_executable(command.display_executable().as_ref()); + println!("Is executable: {}, {}", command.display_executable(), is_executable); + // Spawn and wait for completion // Standard input, output, and error streams are all inherited // TODO(zanieb): Throw a nicer error message if the command is not found @@ -1519,3 +1521,77 @@ fn read_recursion_depth_from_environment_variable() -> anyhow::Result { .parse::() .with_context(|| format!("invalid value for {}", EnvVars::UV_RUN_RECURSION_DEPTH)) } + + +/// Matches valid Python executable names: +/// - ✅ "python", "python3", "python3.9", "python4", "python3.10" +/// - ❌ "python39", "python3abc", "python3.12b3", "python3.13.3" +fn is_python_executable(executable_command: &str) -> bool { + executable_command + .strip_prefix("python") + .map_or(false, |version| version.len() == 0 || is_valid_python_version(version)) +} + +/// Checks if a version string is a valid Python major.minor version (without patch). +fn is_valid_python_version(version: &str) -> bool { + PythonVersion::from_str(version) + .map_or(false, + |ver| + ver.patch().is_none() && + ver.is_stable() && + // Should not contain post info. E.g. "3.12b3" + !ver.is_post() + ) +}#[cfg(test)] +mod tests { + use super::{is_python_executable, is_valid_python_version}; + + /// Helper function for asserting test cases. + /// - If `expected_result` is `true`, it expects the function to return `true` (valid cases). + /// - If `expected_result` is `false`, it expects the function to return `false` (invalid cases). + fn assert_cases bool>(cases: &[&str], func: F, test_name: &str, expected_result: bool) { + for &case in cases { + assert_eq!( + func(case), + expected_result, + "{}: Expected {} but failed on case: {}", + test_name, + expected_result, + case + ); + } + } + + #[test] + fn valid_is_python_executable() { + let valid_cases = [ + "python3", "python3.9", "python3.10", "python4", "python", + "python39", // Still a valid executable, although likely a typo + ]; + assert_cases(&valid_cases, is_python_executable, "valid_is_python_executable", true); + } + + #[test] + fn invalid_is_python_executable() { + let invalid_cases = [ + "python-foo", "python3abc", "python3.12b3", "python3.13.3", + "pyth0n3", "", "Python3.9", "python.3.9" + ]; + assert_cases(&invalid_cases, is_python_executable, "invalid_is_python_executable", false); + } + + #[test] + fn valid_python_versions() { + let valid_cases = ["3", "3.9", "4", "3.10", "49"]; + assert_cases(&valid_cases, is_valid_python_version, "valid_python_versions", true); + } + + #[test] + fn invalid_python_versions() { + let invalid_cases = [ + "3.9.1", "3.12b3", "3.12rc1", "3.12a1", + "3.12.post1", "3.12-foo", "3abc", "..", "" + ]; + assert_cases(&invalid_cases, is_valid_python_version, "invalid_python_versions", false); + } +} From f2e1f33949c1fcadd81f76badf126fda7f036203 Mon Sep 17 00:00:00 2001 From: Jay Lee Date: Sun, 16 Mar 2025 18:57:42 +0900 Subject: [PATCH 2/7] feat: Add meaningful message when attempting to invoke python using run --- crates/uv/src/commands/project/run.rs | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 44fc9eab4ffe..a39ae9e0c996 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -1068,15 +1068,33 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl if interpreter.is_virtualenv() { process.env(EnvVars::VIRTUAL_ENV, interpreter.sys_prefix().as_os_str()); }; - - let is_executable = is_python_executable(command.display_executable().as_ref()); - println!("Is executable: {}, {}", command.display_executable(), is_executable); // Spawn and wait for completion // Standard input, output, and error streams are all inherited // TODO(zanieb): Throw a nicer error message if the command is not found let handle = process .spawn() + .map_err(|err| { + let executable: Cow<'_, str> = command.display_executable(); + // Special case for providing meaningful error message when users + // attempt to invoke python. E.g. "python3.11". + // Will not work if patch version is provided. I.E. "python3.11.9" + if err.kind() == std::io::ErrorKind::NotFound && is_python_executable(&executable) { + // Get version from python command string + // e.g. python3.12 -> "3.12" or "" if python version not specified. + let version_part = executable.strip_prefix("python").unwrap_or(""); + anyhow!( + "`{}` not available in the current environment, which uses python `{}`. + Did you mean `uv run -p {} python` or `uvx python@{}`?", + executable, + base_interpreter.python_version().only_release(), + version_part, + version_part + ) + } else { + err.into() + } + }) .with_context(|| format!("Failed to spawn: `{}`", command.display_executable()))?; run_to_completion(handle).await From 0867ca0dcdc0af0718b11a14c7f3e66335186738 Mon Sep 17 00:00:00 2001 From: Jay Lee Date: Sun, 16 Mar 2025 19:02:06 +0900 Subject: [PATCH 3/7] fix: enable message to show for patch versions --- crates/uv/src/commands/project/run.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index a39ae9e0c996..70545a1b6dca 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -1542,20 +1542,19 @@ fn read_recursion_depth_from_environment_variable() -> anyhow::Result { /// Matches valid Python executable names: -/// - ✅ "python", "python3", "python3.9", "python4", "python3.10" -/// - ❌ "python39", "python3abc", "python3.12b3", "python3.13.3" +/// - ✅ "python", "python3", "python3.9", "python4", "python3.10", "python3.13.3" +/// - ❌ "python39", "python3abc", "python3.12b3", "", "python-foo" fn is_python_executable(executable_command: &str) -> bool { executable_command .strip_prefix("python") .map_or(false, |version| version.len() == 0 || is_valid_python_version(version)) } -/// Checks if a version string is a valid Python major.minor version (without patch). +/// Checks if a version string is a valid Python major.minor.patch version. fn is_valid_python_version(version: &str) -> bool { PythonVersion::from_str(version) .map_or(false, |ver| - ver.patch().is_none() && ver.is_stable() && // Should not contain post info. E.g. "3.12b3" !ver.is_post() @@ -1584,6 +1583,7 @@ mod tests { fn valid_is_python_executable() { let valid_cases = [ "python3", "python3.9", "python3.10", "python4", "python", + "python3.11.3", "python39", // Still a valid executable, although likely a typo ]; assert_cases(&valid_cases, is_python_executable, "valid_is_python_executable", true); @@ -1592,7 +1592,7 @@ mod tests { #[test] fn invalid_is_python_executable() { let invalid_cases = [ - "python-foo", "python3abc", "python3.12b3", "python3.13.3", + "python-foo", "python3abc", "python3.12b3", "pyth0n3", "", "Python3.9", "python.3.9" ]; assert_cases(&invalid_cases, is_python_executable, "invalid_is_python_executable", false); @@ -1600,15 +1600,15 @@ mod tests { #[test] fn valid_python_versions() { - let valid_cases = ["3", "3.9", "4", "3.10", "49"]; + let valid_cases = ["3", "3.9", "4", "3.10", "49", "3.11.3"]; assert_cases(&valid_cases, is_valid_python_version, "valid_python_versions", true); } #[test] fn invalid_python_versions() { let invalid_cases = [ - "3.9.1", "3.12b3", "3.12rc1", "3.12a1", - "3.12.post1", "3.12-foo", "3abc", "..", "" + "3.12b3", "3.12rc1", "3.12a1", + "3.12.post1", "3.12.1-foo", "3abc", "..", "" ]; assert_cases(&invalid_cases, is_valid_python_version, "invalid_python_versions", false); } From a78be217e11a0616522772ebac5010d99ad6ce73 Mon Sep 17 00:00:00 2001 From: Jay Lee Date: Sun, 16 Mar 2025 20:51:54 +0900 Subject: [PATCH 4/7] feat: add support for different message depending on if project is found --- crates/uv/src/commands/project/run.rs | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 70545a1b6dca..80397b65f6c0 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -143,6 +143,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl let lock_state = UniversalState::default(); let sync_state = lock_state.fork(); let workspace_cache = WorkspaceCache::default(); + let mut project_found = true; // Read from the `.env` file, if necessary. if !no_env_file { @@ -786,6 +787,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl venv.into_interpreter() } else { debug!("No project found; searching for Python interpreter"); + project_found = false; let interpreter = { let client_builder = BaseClientBuilder::new() @@ -1083,13 +1085,28 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl // Get version from python command string // e.g. python3.12 -> "3.12" or "" if python version not specified. let version_part = executable.strip_prefix("python").unwrap_or(""); + let current_executable_python_version = base_interpreter.python_version().only_release(); + // Determine the environment type + let env_type = if project_found { "the project" } else { "the" }; + + // Construct the message dynamically + let message_suffix = if project_found { + format!( + "Did you mean to change the environment to Python {} with `uv run -p {} python`?", + version_part, version_part + ) + } else { + format!( + "Did you mean to search for a Python {} environment with `uv run -p {} python`?", + version_part, version_part + ) + }; anyhow!( - "`{}` not available in the current environment, which uses python `{}`. - Did you mean `uv run -p {} python` or `uvx python@{}`?", + "`{}` not available in {} environment, which uses python `{}`. {}", executable, - base_interpreter.python_version().only_release(), - version_part, - version_part + env_type, + current_executable_python_version, + message_suffix ) } else { err.into() From c363629db4678ee235412bab67c024153258c72c Mon Sep 17 00:00:00 2001 From: Jay Lee Date: Sun, 16 Mar 2025 21:19:08 +0900 Subject: [PATCH 5/7] fix: run lint --- crates/uv/src/commands/project/run.rs | 97 ++++++++++++++++++--------- 1 file changed, 66 insertions(+), 31 deletions(-) diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 80397b65f6c0..5e864ed0ae84 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -26,7 +26,9 @@ use uv_fs::{PythonExt, Simplified}; use uv_installer::{SatisfiesResult, SitePackages}; use uv_normalize::PackageName; use uv_python::{ - EnvironmentPreference, Interpreter, PyVenvConfiguration, PythonDownloads, PythonEnvironment, PythonInstallation, PythonPreference, PythonRequest, PythonVersion, PythonVersionFile, VersionFileDiscoveryOptions + EnvironmentPreference, Interpreter, PyVenvConfiguration, PythonDownloads, PythonEnvironment, + PythonInstallation, PythonPreference, PythonRequest, PythonVersion, PythonVersionFile, + VersionFileDiscoveryOptions, }; use uv_requirements::{RequirementsSource, RequirementsSpecification}; use uv_resolver::Lock; @@ -1070,14 +1072,14 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl if interpreter.is_virtualenv() { process.env(EnvVars::VIRTUAL_ENV, interpreter.sys_prefix().as_os_str()); }; - + // Spawn and wait for completion // Standard input, output, and error streams are all inherited // TODO(zanieb): Throw a nicer error message if the command is not found let handle = process .spawn() .map_err(|err| { - let executable: Cow<'_, str> = command.display_executable(); + let executable: Cow<'_, str> = command.display_executable(); // Special case for providing meaningful error message when users // attempt to invoke python. E.g. "python3.11". // Will not work if patch version is provided. I.E. "python3.11.9" @@ -1092,13 +1094,11 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl // Construct the message dynamically let message_suffix = if project_found { format!( - "Did you mean to change the environment to Python {} with `uv run -p {} python`?", - version_part, version_part + "Did you mean to change the environment to Python {version_part} with `uv run -p {version_part} python`?" ) } else { format!( - "Did you mean to search for a Python {} environment with `uv run -p {} python`?", - version_part, version_part + "Did you mean to search for a Python {version_part} environment with `uv run -p {version_part} python`?" ) }; anyhow!( @@ -1557,41 +1557,41 @@ fn read_recursion_depth_from_environment_variable() -> anyhow::Result { .with_context(|| format!("invalid value for {}", EnvVars::UV_RUN_RECURSION_DEPTH)) } - /// Matches valid Python executable names: /// - ✅ "python", "python3", "python3.9", "python4", "python3.10", "python3.13.3" /// - ❌ "python39", "python3abc", "python3.12b3", "", "python-foo" fn is_python_executable(executable_command: &str) -> bool { executable_command .strip_prefix("python") - .map_or(false, |version| version.len() == 0 || is_valid_python_version(version)) + .is_some_and(|version| version.is_empty() || is_valid_python_version(version)) } /// Checks if a version string is a valid Python major.minor.patch version. fn is_valid_python_version(version: &str) -> bool { - PythonVersion::from_str(version) - .map_or(false, - |ver| - ver.is_stable() && + PythonVersion::from_str(version).is_ok_and(|ver| { + ver.is_stable() && // Should not contain post info. E.g. "3.12b3" !ver.is_post() - ) -}#[cfg(test)] + }) +} +#[cfg(test)] mod tests { use super::{is_python_executable, is_valid_python_version}; /// Helper function for asserting test cases. /// - If `expected_result` is `true`, it expects the function to return `true` (valid cases). /// - If `expected_result` is `false`, it expects the function to return `false` (invalid cases). - fn assert_cases bool>(cases: &[&str], func: F, test_name: &str, expected_result: bool) { + fn assert_cases bool>( + cases: &[&str], + func: F, + test_name: &str, + expected_result: bool, + ) { for &case in cases { + let result = func(case); assert_eq!( - func(case), - expected_result, - "{}: Expected {} but failed on case: {}", - test_name, - expected_result, - case + result, expected_result, + "{test_name}: Expected `{expected_result}`, but got `{result}` for case `{case}`" ); } } @@ -1599,34 +1599,69 @@ mod tests { #[test] fn valid_is_python_executable() { let valid_cases = [ - "python3", "python3.9", "python3.10", "python4", "python", + "python3", + "python3.9", + "python3.10", + "python4", + "python", "python3.11.3", "python39", // Still a valid executable, although likely a typo ]; - assert_cases(&valid_cases, is_python_executable, "valid_is_python_executable", true); + assert_cases( + &valid_cases, + is_python_executable, + "valid_is_python_executable", + true, + ); } #[test] fn invalid_is_python_executable() { let invalid_cases = [ - "python-foo", "python3abc", "python3.12b3", - "pyth0n3", "", "Python3.9", "python.3.9" + "python-foo", + "python3abc", + "python3.12b3", + "pyth0n3", + "", + "Python3.9", + "python.3.9", ]; - assert_cases(&invalid_cases, is_python_executable, "invalid_is_python_executable", false); + assert_cases( + &invalid_cases, + is_python_executable, + "invalid_is_python_executable", + false, + ); } #[test] fn valid_python_versions() { let valid_cases = ["3", "3.9", "4", "3.10", "49", "3.11.3"]; - assert_cases(&valid_cases, is_valid_python_version, "valid_python_versions", true); + assert_cases( + &valid_cases, + is_valid_python_version, + "valid_python_versions", + true, + ); } #[test] fn invalid_python_versions() { let invalid_cases = [ - "3.12b3", "3.12rc1", "3.12a1", - "3.12.post1", "3.12.1-foo", "3abc", "..", "" + "3.12b3", + "3.12rc1", + "3.12a1", + "3.12.post1", + "3.12.1-foo", + "3abc", + "..", + "", ]; - assert_cases(&invalid_cases, is_valid_python_version, "invalid_python_versions", false); + assert_cases( + &invalid_cases, + is_valid_python_version, + "invalid_python_versions", + false, + ); } } From 2d1c7710b86e6b02605386501f48acb7775909c0 Mon Sep 17 00:00:00 2001 From: Jay Lee Date: Tue, 18 Mar 2025 13:34:48 +0900 Subject: [PATCH 6/7] fix: change message for project --- crates/uv/src/commands/project/run.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 5e864ed0ae84..76a9ddfe6f0f 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -1089,9 +1089,8 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl let version_part = executable.strip_prefix("python").unwrap_or(""); let current_executable_python_version = base_interpreter.python_version().only_release(); // Determine the environment type - let env_type = if project_found { "the project" } else { "the" }; + let env_type = if project_found { "project" } else { "virtual" }; - // Construct the message dynamically let message_suffix = if project_found { format!( "Did you mean to change the environment to Python {version_part} with `uv run -p {version_part} python`?" @@ -1102,7 +1101,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl ) }; anyhow!( - "`{}` not available in {} environment, which uses python `{}`. {}", + "`{}` not available in the {} environment, which uses python `{}`. {}", executable, env_type, current_executable_python_version, From 1991d43b9b913b35d7b7ca3d11a0c6257c9bb3ce Mon Sep 17 00:00:00 2001 From: Jay Lee Date: Tue, 18 Mar 2025 13:36:04 +0900 Subject: [PATCH 7/7] docs: update docstring --- crates/uv/src/commands/project/run.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 76a9ddfe6f0f..cef43d53899c 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -1557,8 +1557,8 @@ fn read_recursion_depth_from_environment_variable() -> anyhow::Result { } /// Matches valid Python executable names: -/// - ✅ "python", "python3", "python3.9", "python4", "python3.10", "python3.13.3" -/// - ❌ "python39", "python3abc", "python3.12b3", "", "python-foo" +/// - ✅ "python", "python39", "python3", "python3.9", "python4", "python3.10", "python3.13.3" +/// - ❌ "python3abc", "python3.12b3", "", "python-foo" fn is_python_executable(executable_command: &str) -> bool { executable_command .strip_prefix("python")