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| {
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<u32> {
/// 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<PythonVersion> {
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<PythonVersion> {
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<F: Fn(&str) -> Option<&str>>(
fn assert_cases<F: Fn(&str) -> Option<PythonVersion>>(
cases: &[(&str, Option<&str>)],
func: F,
test_name: &str,
) {
for &(case, expected) in cases {
let result = func(case);
match (result, expected) {
(Some(version), Some(expected_str)) => {
assert_eq!(
result, expected,
"{test_name}: Expected `{expected:?}`, but got `{result:?}` for case `{case}`"
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"
);
}

View file

@ -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(())