feat: provide meaningful error message when python patch version is provided

This commit is contained in:
Jay Lee 2025-04-26 17:52:22 +09:00
parent 7433028ea5
commit 99765005f3
2 changed files with 98 additions and 52 deletions

View file

@ -1111,6 +1111,14 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl
|specified_version| { |specified_version| {
let current_executable_python_version = base_interpreter.python_version().only_release(); let current_executable_python_version = base_interpreter.python_version().only_release();
let env_type = if project_found { "project" } else { "virtual" }; 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 { let message_suffix = if project_found {
format!( format!(
"Did you mean to change the environment to Python {specified_version} with `uv run -p {specified_version} python`?" "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!( anyhow!(
"`{}` not available in the {} environment, which uses python `{}`. {}", "{} {}",
executable, message_prefix,
env_type,
current_executable_python_version,
message_suffix message_suffix
) )
} }
@ -1580,72 +1586,95 @@ fn read_recursion_depth_from_environment_variable() -> anyhow::Result<u32> {
/// Matches valid Python executable names and returns the version part if valid: /// Matches valid Python executable names and returns the version part if valid:
/// - ✅ "python" -> Some("") /// - ✅ "python" -> Some("")
/// - ✅ "/usr/bin/python3.9" -> Some("3.9") /// - ✅ "/usr/bin/python3.9" -> Some("3.9")
/// - ✅ "/path/to/python39" -> Some("39") /// - ✅ "python39" -> Some("39")
/// - ✅ "python3" -> Some("3") /// - ✅ "python3" -> Some("3")
/// - ✅ "python3.exe" -> Some("3") /// - ✅ "python3.exe" -> Some("3")
/// - ✅ "python3.9.exe" -> Some("3.9") /// - ✅ "python3.9.exe" -> Some("3.9")
/// - ✅ "python3.9.EXE" -> Some("3.9")
/// - ❌ "python3abc" -> None /// - ❌ "python3abc" -> None
/// - ❌ "python3.12b3" -> None /// - ❌ "python3.12b3" -> None
/// - ❌ "" -> None /// - ❌ "" -> None
/// - ❌ "python-foo" -> None /// - ❌ "python-foo" -> None
/// - ❌ "Python" -> None // Case-sensitive /// - ❌ "Python3.9" -> None // Case-sensitive prefix
fn python_executable_version(executable_command: &str) -> Option<&str> { fn python_executable_version(executable_command: &str) -> Option<PythonVersion> {
const PYTHON_MARKER: &str = "python"; const PYTHON_MARKER: &str = "python";
// Strip suffix for windows .exe // Find the python prefix (case-sensitive)
let command = executable_command let version_start = executable_command.rfind(PYTHON_MARKER)? + PYTHON_MARKER.len();
.strip_suffix(".exe") let mut version = &executable_command[version_start..];
.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..)?;
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 a version string is a valid Python major.minor.patch version.
/// Returns Ok(()) if valid, Err with description if invalid. fn parse_valid_python_version(version: &str) -> anyhow::Result<PythonVersion> {
fn validate_python_version(version: &str) -> anyhow::Result<()> {
match PythonVersion::from_str(version) { 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)), _ => Err(anyhow!("invalid python version: {}", version)),
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { 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. /// Helper function for asserting test cases.
/// - If `expected_result` is `Some(version)`, it expects the function to return that version. /// - 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). /// - If `expected_result` is `None`, it expects the function to return None (invalid cases).
fn assert_cases<F: Fn(&str) -> Option<&str>>( fn assert_cases<F: Fn(&str) -> Option<PythonVersion>>(
cases: &[(&str, Option<&str>)], cases: &[(&str, Option<&str>)],
func: F, func: F,
test_name: &str, test_name: &str,
) { ) {
for &(case, expected) in cases { for &(case, expected) in cases {
let result = func(case); let result = func(case);
assert_eq!( match (result, expected) {
result, expected, (Some(version), Some(expected_str)) => {
"{test_name}: Expected `{expected:?}`, but got `{result:?}` for case `{case}`" 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] #[test]
fn valid_python_executable_version() { fn valid_python_executable_version() {
let valid_cases = [ let valid_cases = [
// Base cases
("python3", Some("3")), ("python3", Some("3")),
("python3.9", Some("3.9")), ("python3.9", Some("3.9")),
("python3.10", Some("3.10")), // Path handling
("/usr/bin/python3.9", Some("3.9")), ("/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")),
("python3.9.exe", Some("3.9")), ("python3.9.EXE", Some("3.9")),
("python4", Some("4")), ("python3.9.exe.EXE", Some("3.9")),
("python", Some("")), // Version variations
("python3.11.3", Some("3.11.3")), ("python3.11.3", Some("3.11.3")),
("python39", Some("39")), // Still a valid executable, although likely a typo ("python39", Some("39")),
]; ];
assert_cases( assert_cases(
&valid_cases, &valid_cases,
@ -1657,13 +1686,20 @@ mod tests {
#[test] #[test]
fn invalid_python_executable_version() { fn invalid_python_executable_version() {
let invalid_cases = [ let invalid_cases = [
("python-foo", None), // Empty string
("python3abc", None),
("python3.12b3", None),
("pyth0n3", None),
("", None), ("", None),
("python", None), // No version specified
// Case-sensitive python prefix
("Python3.9", None), ("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( assert_cases(
&invalid_cases, &invalid_cases,
@ -1674,30 +1710,40 @@ mod tests {
#[test] #[test]
fn valid_python_versions() { fn valid_python_versions() {
let valid_cases: &[&str] = &["3", "3.9", "4", "3.10", "49", "3.11.3"]; let valid_cases = [
for version in 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!( assert!(
validate_python_version(version).is_ok(), result.is_ok(),
"Expected version `{version}` to be valid" "Expected version `{version}` to be valid, but got error: {:?}",
result.err()
);
assert_eq!(
result.unwrap().to_string(),
expected,
"Version string mismatch for {version}"
); );
} }
} }
#[test] #[test]
fn invalid_python_versions() { fn invalid_python_versions() {
let invalid_cases: &[&str] = &[ let invalid_cases = [
"3.12b3", "3.12b3", // Pre-release
"3.12rc1", "3.12rc1", // Release candidate
"3.12a1", "3.12.post1", // Post-release
"3.12.post1", "3abc", // Invalid format
"3.12.1-foo", "..", // Invalid format
"3abc", "", // Empty string
"..",
"",
]; ];
for version in invalid_cases { for version in invalid_cases {
assert!( assert!(
validate_python_version(version).is_err(), parse_valid_python_version(version).is_err(),
"Expected version `{version}` to be invalid" "Expected version `{version}` to be invalid"
); );
} }

View file

@ -179,7 +179,7 @@ fn run_matching_python_patch_version() -> Result<()> {
Resolved 1 package in [TIME] Resolved 1 package in [TIME]
Audited in [TIME] Audited in [TIME]
error: Failed to spawn: `python3.11.9` 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(()) Ok(())
@ -220,7 +220,7 @@ fn run_missing_python_patch_version_no_project() {
----- stderr ----- ----- stderr -----
error: Failed to spawn: `python3.11.9` 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] Resolved 1 package in [TIME]
Audited in [TIME] Audited in [TIME]
error: Failed to spawn: `python3.11.9` 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(()) Ok(())