mirror of
https://github.com/astral-sh/uv.git
synced 2025-07-07 21:35:00 +00:00
feat: provide meaningful error message when python patch version is provided
This commit is contained in:
parent
7433028ea5
commit
99765005f3
2 changed files with 98 additions and 52 deletions
|
@ -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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(())
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue