From 99765005f3ef2e5e4da44a83a300980bdcd49b42 Mon Sep 17 00:00:00 2001 From: Jay Lee Date: Sat, 26 Apr 2025 17:52:22 +0900 Subject: [PATCH] feat: provide meaningful error message when python patch version is provided --- crates/uv/src/commands/project/run.rs | 144 +++++++++++++++++--------- crates/uv/tests/it/run.rs | 6 +- 2 files changed, 98 insertions(+), 52 deletions(-) diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 004349115..6dabb0f6a 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -1111,6 +1111,14 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl |specified_version| { let current_executable_python_version = base_interpreter.python_version().only_release(); let env_type = if project_found { "project" } else { "virtual" }; + // Specified version is equal. In this case, + let message_prefix = if specified_version.patch().is_some() { + let major = specified_version.major(); + let minor = specified_version.minor(); + format!("Please omit patch version. Try: `uv run python{major}.{minor}`.") + } else { + format!("`{executable}` not available in the {env_type} environment, which uses python `{current_executable_python_version}`.") + }; let message_suffix = if project_found { format!( "Did you mean to change the environment to Python {specified_version} with `uv run -p {specified_version} python`?" @@ -1121,10 +1129,8 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl ) }; anyhow!( - "`{}` not available in the {} environment, which uses python `{}`. {}", - executable, - env_type, - current_executable_python_version, + "{} {}", + message_prefix, message_suffix ) } @@ -1580,72 +1586,95 @@ fn read_recursion_depth_from_environment_variable() -> anyhow::Result { /// Matches valid Python executable names and returns the version part if valid: /// - ✅ "python" -> Some("") /// - ✅ "/usr/bin/python3.9" -> Some("3.9") -/// - ✅ "/path/to/python39" -> Some("39") +/// - ✅ "python39" -> Some("39") /// - ✅ "python3" -> Some("3") /// - ✅ "python3.exe" -> Some("3") /// - ✅ "python3.9.exe" -> Some("3.9") +/// - ✅ "python3.9.EXE" -> Some("3.9") /// - ❌ "python3abc" -> None /// - ❌ "python3.12b3" -> None /// - ❌ "" -> None /// - ❌ "python-foo" -> None -/// - ❌ "Python" -> None // Case-sensitive -fn python_executable_version(executable_command: &str) -> Option<&str> { +/// - ❌ "Python3.9" -> None // Case-sensitive prefix +fn python_executable_version(executable_command: &str) -> Option { const PYTHON_MARKER: &str = "python"; - // Strip suffix for windows .exe - let command = executable_command - .strip_suffix(".exe") - .unwrap_or(executable_command); - let version_start = command.rfind(PYTHON_MARKER)? + PYTHON_MARKER.len(); - // Retrieve python version string. E.g. "python3.12" -> "3.12" - let version = command.get(version_start..)?; + // Find the python prefix (case-sensitive) + let version_start = executable_command.rfind(PYTHON_MARKER)? + PYTHON_MARKER.len(); + let mut version = &executable_command[version_start..]; - Some(version).filter(|&v| v.is_empty() || validate_python_version(v).is_ok()) + // Strip any .exe suffixes (case-insensitive) + while version.to_ascii_lowercase().ends_with(".exe") { + version = &version[..version.len() - 4]; + } + if version.is_empty() { + return None; + } + parse_valid_python_version(version).ok() } -/// Validates if a version string is a valid Python major.minor.patch version. -/// Returns Ok(()) if valid, Err with description if invalid. -fn validate_python_version(version: &str) -> anyhow::Result<()> { +/// Returns Ok(()) if a version string is a valid Python major.minor.patch version. +fn parse_valid_python_version(version: &str) -> anyhow::Result { match PythonVersion::from_str(version) { - Ok(ver) if ver.is_stable() && !ver.is_post() => Ok(()), + Ok(ver) if ver.is_stable() && !ver.is_post() => Ok(ver), _ => Err(anyhow!("invalid python version: {}", version)), } } #[cfg(test)] mod tests { - use super::{python_executable_version, validate_python_version}; + use uv_python::PythonVersion; + + use super::{parse_valid_python_version, python_executable_version}; /// Helper function for asserting test cases. /// - If `expected_result` is `Some(version)`, it expects the function to return that version. /// - If `expected_result` is `None`, it expects the function to return None (invalid cases). - fn assert_cases Option<&str>>( + fn assert_cases Option>( cases: &[(&str, Option<&str>)], func: F, test_name: &str, ) { for &(case, expected) in cases { let result = func(case); - assert_eq!( - result, expected, - "{test_name}: Expected `{expected:?}`, but got `{result:?}` for case `{case}`" - ); + match (result, expected) { + (Some(version), Some(expected_str)) => { + assert_eq!( + version.to_string(), + expected_str, + "{test_name}: Expected version `{expected_str}`, but got `{version}` for case `{case}`" + ); + } + (None, None) => { + // Test passed - both are None + } + (Some(version), None) => { + panic!("{test_name}: Expected None, but got `{version}` for case `{case}`"); + } + (None, Some(expected_str)) => { + panic!( + "{test_name}: Expected `{expected_str}`, but got None for case `{case}`" + ); + } + } } } #[test] fn valid_python_executable_version() { let valid_cases = [ + // Base cases ("python3", Some("3")), ("python3.9", Some("3.9")), - ("python3.10", Some("3.10")), + // Path handling ("/usr/bin/python3.9", Some("3.9")), + // Case-sensitive python prefix, case-insensitive .exe ("python3.9.exe", Some("3.9")), - ("python3.9.exe", Some("3.9")), - ("python4", Some("4")), - ("python", Some("")), + ("python3.9.EXE", Some("3.9")), + ("python3.9.exe.EXE", Some("3.9")), + // Version variations ("python3.11.3", Some("3.11.3")), - ("python39", Some("39")), // Still a valid executable, although likely a typo + ("python39", Some("39")), ]; assert_cases( &valid_cases, @@ -1657,13 +1686,20 @@ mod tests { #[test] fn invalid_python_executable_version() { let invalid_cases = [ - ("python-foo", None), - ("python3abc", None), - ("python3.12b3", None), - ("pyth0n3", None), + // Empty string ("", None), + ("python", None), // No version specified + // Case-sensitive python prefix ("Python3.9", None), - ("python.3.9", None), + ("PYTHON3.9", None), + ("Python3.9.exe", None), + ("Python3.9.EXE", None), + // Invalid version formats + ("python3.12b3", None), + ("python3.12.post1", None), + // Invalid .exe placement/format + ("python.exe3.9", None), + ("python3.9.ex", None), ]; assert_cases( &invalid_cases, @@ -1674,30 +1710,40 @@ mod tests { #[test] fn valid_python_versions() { - let valid_cases: &[&str] = &["3", "3.9", "4", "3.10", "49", "3.11.3"]; - for version in valid_cases { + let valid_cases = [ + ("3", "3"), + ("3.9", "3.9"), + ("3.10", "3.10"), + ("3.11.3", "3.11.3"), + ]; + for (version, expected) in valid_cases { + let result = parse_valid_python_version(version); assert!( - validate_python_version(version).is_ok(), - "Expected version `{version}` to be valid" + result.is_ok(), + "Expected version `{version}` to be valid, but got error: {:?}", + result.err() + ); + assert_eq!( + result.unwrap().to_string(), + expected, + "Version string mismatch for {version}" ); } } #[test] fn invalid_python_versions() { - let invalid_cases: &[&str] = &[ - "3.12b3", - "3.12rc1", - "3.12a1", - "3.12.post1", - "3.12.1-foo", - "3abc", - "..", - "", + let invalid_cases = [ + "3.12b3", // Pre-release + "3.12rc1", // Release candidate + "3.12.post1", // Post-release + "3abc", // Invalid format + "..", // Invalid format + "", // Empty string ]; for version in invalid_cases { assert!( - validate_python_version(version).is_err(), + parse_valid_python_version(version).is_err(), "Expected version `{version}` to be invalid" ); } diff --git a/crates/uv/tests/it/run.rs b/crates/uv/tests/it/run.rs index 87345ffc5..ba0f64203 100644 --- a/crates/uv/tests/it/run.rs +++ b/crates/uv/tests/it/run.rs @@ -179,7 +179,7 @@ fn run_matching_python_patch_version() -> Result<()> { Resolved 1 package in [TIME] Audited in [TIME] error: Failed to spawn: `python3.11.9` - Caused by: `python3.11.9` not available in the project environment, which uses python `3.11.9`. Did you mean to change the environment to Python 3.11.9 with `uv run -p 3.11.9 python`? + Caused by: Please omit patch version. Try: `uv run python3.11`. Did you mean to change the environment to Python 3.11.9 with `uv run -p 3.11.9 python`? "); Ok(()) @@ -220,7 +220,7 @@ fn run_missing_python_patch_version_no_project() { ----- stderr ----- error: Failed to spawn: `python3.11.9` - Caused by: `python3.11.9` not available in the virtual environment, which uses python `3.12.[X]`. Did you mean to search for a Python 3.11.9 environment with `uv run -p 3.11.9 python`? + Caused by: Please omit patch version. Try: `uv run python3.11`. Did you mean to search for a Python 3.11.9 environment with `uv run -p 3.11.9 python`? "); } @@ -292,7 +292,7 @@ fn run_missing_python_patch_version_in_project() -> Result<()> { Resolved 1 package in [TIME] Audited in [TIME] error: Failed to spawn: `python3.11.9` - Caused by: `python3.11.9` not available in the project environment, which uses python `3.12.[X]`. Did you mean to change the environment to Python 3.11.9 with `uv run -p 3.11.9 python`? + Caused by: Please omit patch version. Try: `uv run python3.11`. Did you mean to change the environment to Python 3.11.9 with `uv run -p 3.11.9 python`? "); Ok(())