Skip to content

Commit 87c1ad7

Browse files
committed
feat: provide meaningful error message when python patch version is provided
1 parent 7433028 commit 87c1ad7

File tree

2 files changed

+97
-51
lines changed

2 files changed

+97
-51
lines changed

crates/uv/src/commands/project/run.rs

+95-49
Original file line numberDiff line numberDiff line change
@@ -1111,6 +1111,14 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl
11111111
|specified_version| {
11121112
let current_executable_python_version = base_interpreter.python_version().only_release();
11131113
let env_type = if project_found { "project" } else { "virtual" };
1114+
// Specified version is equal. In this case,
1115+
let message_prefix = if specified_version.patch().is_some() {
1116+
let major = specified_version.major();
1117+
let minor = specified_version.minor();
1118+
format!("Please omit patch version. Try: `uv run python{major}.{minor}`.")
1119+
} else {
1120+
format!("`{executable}` not available in the {env_type} environment, which uses python `{current_executable_python_version}`.")
1121+
};
11141122
let message_suffix = if project_found {
11151123
format!(
11161124
"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
11211129
)
11221130
};
11231131
anyhow!(
1124-
"`{}` not available in the {} environment, which uses python `{}`. {}",
1125-
executable,
1126-
env_type,
1127-
current_executable_python_version,
1132+
"{} {}",
1133+
message_prefix,
11281134
message_suffix
11291135
)
11301136
}
@@ -1580,72 +1586,95 @@ fn read_recursion_depth_from_environment_variable() -> anyhow::Result<u32> {
15801586
/// Matches valid Python executable names and returns the version part if valid:
15811587
/// - ✅ "python" -> Some("")
15821588
/// - ✅ "/usr/bin/python3.9" -> Some("3.9")
1583-
/// - ✅ "/path/to/python39" -> Some("39")
1589+
/// - ✅ "python39" -> Some("39")
15841590
/// - ✅ "python3" -> Some("3")
15851591
/// - ✅ "python3.exe" -> Some("3")
15861592
/// - ✅ "python3.9.exe" -> Some("3.9")
1593+
/// - ✅ "python3.9.EXE" -> Some("3.9")
15871594
/// - ❌ "python3abc" -> None
15881595
/// - ❌ "python3.12b3" -> None
15891596
/// - ❌ "" -> None
15901597
/// - ❌ "python-foo" -> None
1591-
/// - ❌ "Python" -> None // Case-sensitive
1592-
fn python_executable_version(executable_command: &str) -> Option<&str> {
1598+
/// - ❌ "Python3.9" -> None // Case-sensitive prefix
1599+
fn python_executable_version(executable_command: &str) -> Option<PythonVersion> {
15931600
const PYTHON_MARKER: &str = "python";
15941601

1595-
// Strip suffix for windows .exe
1596-
let command = executable_command
1597-
.strip_suffix(".exe")
1598-
.unwrap_or(executable_command);
1599-
let version_start = command.rfind(PYTHON_MARKER)? + PYTHON_MARKER.len();
1600-
// Retrieve python version string. E.g. "python3.12" -> "3.12"
1601-
let version = command.get(version_start..)?;
1602+
// Find the python prefix (case-sensitive)
1603+
let version_start = executable_command.rfind(PYTHON_MARKER)? + PYTHON_MARKER.len();
1604+
let mut version = &executable_command[version_start..];
16021605

1603-
Some(version).filter(|&v| v.is_empty() || validate_python_version(v).is_ok())
1606+
// Strip any .exe suffixes (case-insensitive)
1607+
while version.to_ascii_lowercase().ends_with(".exe") {
1608+
version = &version[..version.len() - 4];
1609+
}
1610+
if version.is_empty() {
1611+
return None;
1612+
}
1613+
parse_valid_python_version(version).ok()
16041614
}
16051615

1606-
/// Validates if a version string is a valid Python major.minor.patch version.
1607-
/// Returns Ok(()) if valid, Err with description if invalid.
1608-
fn validate_python_version(version: &str) -> anyhow::Result<()> {
1616+
/// Returns Ok(()) if a version string is a valid Python major.minor.patch version.
1617+
fn parse_valid_python_version(version: &str) -> anyhow::Result<PythonVersion> {
16091618
match PythonVersion::from_str(version) {
1610-
Ok(ver) if ver.is_stable() && !ver.is_post() => Ok(()),
1619+
Ok(ver) if ver.is_stable() && !ver.is_post() => Ok(ver),
16111620
_ => Err(anyhow!("invalid python version: {}", version)),
16121621
}
16131622
}
16141623

16151624
#[cfg(test)]
16161625
mod tests {
1617-
use super::{python_executable_version, validate_python_version};
1626+
use uv_python::PythonVersion;
1627+
1628+
use super::{parse_valid_python_version, python_executable_version};
16181629

16191630
/// Helper function for asserting test cases.
16201631
/// - If `expected_result` is `Some(version)`, it expects the function to return that version.
16211632
/// - If `expected_result` is `None`, it expects the function to return None (invalid cases).
1622-
fn assert_cases<F: Fn(&str) -> Option<&str>>(
1633+
fn assert_cases<F: Fn(&str) -> Option<PythonVersion>>(
16231634
cases: &[(&str, Option<&str>)],
16241635
func: F,
16251636
test_name: &str,
16261637
) {
16271638
for &(case, expected) in cases {
16281639
let result = func(case);
1629-
assert_eq!(
1630-
result, expected,
1631-
"{test_name}: Expected `{expected:?}`, but got `{result:?}` for case `{case}`"
1632-
);
1640+
match (result, expected) {
1641+
(Some(version), Some(expected_str)) => {
1642+
assert_eq!(
1643+
version.to_string(),
1644+
expected_str,
1645+
"{test_name}: Expected version `{expected_str}`, but got `{version}` for case `{case}`"
1646+
);
1647+
}
1648+
(None, None) => {
1649+
// Test passed - both are None
1650+
}
1651+
(Some(version), None) => {
1652+
panic!("{test_name}: Expected None, but got `{version}` for case `{case}`");
1653+
}
1654+
(None, Some(expected_str)) => {
1655+
panic!(
1656+
"{test_name}: Expected `{expected_str}`, but got None for case `{case}`"
1657+
);
1658+
}
1659+
}
16331660
}
16341661
}
16351662

16361663
#[test]
16371664
fn valid_python_executable_version() {
16381665
let valid_cases = [
1666+
// Base cases
16391667
("python3", Some("3")),
16401668
("python3.9", Some("3.9")),
1641-
("python3.10", Some("3.10")),
1669+
// Path handling
16421670
("/usr/bin/python3.9", Some("3.9")),
1671+
// Case-sensitive python prefix, case-insensitive .exe
16431672
("python3.9.exe", Some("3.9")),
1644-
("python3.9.exe", Some("3.9")),
1645-
("python4", Some("4")),
1646-
("python", Some("")),
1673+
("python3.9.EXE", Some("3.9")),
1674+
("python3.9.exe.EXE", Some("3.9")),
1675+
// Version variations
16471676
("python3.11.3", Some("3.11.3")),
1648-
("python39", Some("39")), // Still a valid executable, although likely a typo
1677+
("python39", Some("39")),
16491678
];
16501679
assert_cases(
16511680
&valid_cases,
@@ -1657,13 +1686,20 @@ mod tests {
16571686
#[test]
16581687
fn invalid_python_executable_version() {
16591688
let invalid_cases = [
1660-
("python-foo", None),
1661-
("python3abc", None),
1662-
("python3.12b3", None),
1663-
("pyth0n3", None),
1689+
// Empty string
16641690
("", None),
1691+
("python", None), // No version specified
1692+
// Case-sensitive python prefix
16651693
("Python3.9", None),
1666-
("python.3.9", None),
1694+
("PYTHON3.9", None),
1695+
("Python3.9.exe", None),
1696+
("Python3.9.EXE", None),
1697+
// Invalid version formats
1698+
("python3.12b3", None),
1699+
("python3.12.post1", None),
1700+
// Invalid .exe placement/format
1701+
("python.exe3.9", None),
1702+
("python3.9.ex", None),
16671703
];
16681704
assert_cases(
16691705
&invalid_cases,
@@ -1674,30 +1710,40 @@ mod tests {
16741710

16751711
#[test]
16761712
fn valid_python_versions() {
1677-
let valid_cases: &[&str] = &["3", "3.9", "4", "3.10", "49", "3.11.3"];
1678-
for version in valid_cases {
1713+
let valid_cases = [
1714+
("3", "3"),
1715+
("3.9", "3.9"),
1716+
("3.10", "3.10"),
1717+
("3.11.3", "3.11.3"),
1718+
];
1719+
for (version, expected) in valid_cases {
1720+
let result = parse_valid_python_version(version);
16791721
assert!(
1680-
validate_python_version(version).is_ok(),
1681-
"Expected version `{version}` to be valid"
1722+
result.is_ok(),
1723+
"Expected version `{version}` to be valid, but got error: {:?}",
1724+
result.err()
1725+
);
1726+
assert_eq!(
1727+
result.unwrap().to_string(),
1728+
expected,
1729+
"Version string mismatch for {version}"
16821730
);
16831731
}
16841732
}
16851733

16861734
#[test]
16871735
fn invalid_python_versions() {
1688-
let invalid_cases: &[&str] = &[
1689-
"3.12b3",
1690-
"3.12rc1",
1691-
"3.12a1",
1692-
"3.12.post1",
1693-
"3.12.1-foo",
1694-
"3abc",
1695-
"..",
1696-
"",
1736+
let invalid_cases = [
1737+
"3.12b3", // Pre-release
1738+
"3.12rc1", // Release candidate
1739+
"3.12.post1", // Post-release
1740+
"3abc", // Invalid format
1741+
"..", // Invalid format
1742+
"", // Empty string
16971743
];
16981744
for version in invalid_cases {
16991745
assert!(
1700-
validate_python_version(version).is_err(),
1746+
parse_valid_python_version(version).is_err(),
17011747
"Expected version `{version}` to be invalid"
17021748
);
17031749
}

crates/uv/tests/it/run.rs

+2-2
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ fn run_missing_python_patch_version_no_project() {
220220
221221
----- stderr -----
222222
error: Failed to spawn: `python3.11.9`
223-
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`?
223+
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`?
224224
");
225225
}
226226

@@ -292,7 +292,7 @@ fn run_missing_python_patch_version_in_project() -> Result<()> {
292292
Resolved 1 package in [TIME]
293293
Audited in [TIME]
294294
error: Failed to spawn: `python3.11.9`
295-
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`?
295+
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`?
296296
");
297297

298298
Ok(())

0 commit comments

Comments
 (0)