@@ -1111,6 +1111,14 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl
1111
1111
|specified_version| {
1112
1112
let current_executable_python_version = base_interpreter. python_version ( ) . only_release ( ) ;
1113
1113
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
+ } ;
1114
1122
let message_suffix = if project_found {
1115
1123
format ! (
1116
1124
"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
1121
1129
)
1122
1130
} ;
1123
1131
anyhow ! (
1124
- "`{}` not available in the {} environment, which uses python `{}`. {}" ,
1125
- executable,
1126
- env_type,
1127
- current_executable_python_version,
1132
+ "{} {}" ,
1133
+ message_prefix,
1128
1134
message_suffix
1129
1135
)
1130
1136
}
@@ -1580,72 +1586,95 @@ fn read_recursion_depth_from_environment_variable() -> anyhow::Result<u32> {
1580
1586
/// Matches valid Python executable names and returns the version part if valid:
1581
1587
/// - ✅ "python" -> Some("")
1582
1588
/// - ✅ "/usr/bin/python3.9" -> Some("3.9")
1583
- /// - ✅ "/path/to/ python39" -> Some("39")
1589
+ /// - ✅ "python39" -> Some("39")
1584
1590
/// - ✅ "python3" -> Some("3")
1585
1591
/// - ✅ "python3.exe" -> Some("3")
1586
1592
/// - ✅ "python3.9.exe" -> Some("3.9")
1593
+ /// - ✅ "python3.9.EXE" -> Some("3.9")
1587
1594
/// - ❌ "python3abc" -> None
1588
1595
/// - ❌ "python3.12b3" -> None
1589
1596
/// - ❌ "" -> None
1590
1597
/// - ❌ "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 > {
1593
1600
const PYTHON_MARKER : & str = "python" ;
1594
1601
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..] ;
1602
1605
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 ( )
1604
1614
}
1605
1615
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 > {
1609
1618
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 ) ,
1611
1620
_ => Err ( anyhow ! ( "invalid python version: {}" , version) ) ,
1612
1621
}
1613
1622
}
1614
1623
1615
1624
#[ cfg( test) ]
1616
1625
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} ;
1618
1629
1619
1630
/// Helper function for asserting test cases.
1620
1631
/// - If `expected_result` is `Some(version)`, it expects the function to return that version.
1621
1632
/// - 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 > > (
1623
1634
cases : & [ ( & str , Option < & str > ) ] ,
1624
1635
func : F ,
1625
1636
test_name : & str ,
1626
1637
) {
1627
1638
for & ( case, expected) in cases {
1628
1639
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
+ }
1633
1660
}
1634
1661
}
1635
1662
1636
1663
#[ test]
1637
1664
fn valid_python_executable_version ( ) {
1638
1665
let valid_cases = [
1666
+ // Base cases
1639
1667
( "python3" , Some ( "3" ) ) ,
1640
1668
( "python3.9" , Some ( "3.9" ) ) ,
1641
- ( "python3.10" , Some ( "3.10" ) ) ,
1669
+ // Path handling
1642
1670
( "/usr/bin/python3.9" , Some ( "3.9" ) ) ,
1671
+ // Case-sensitive python prefix, case-insensitive .exe
1643
1672
( "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
1647
1676
( "python3.11.3" , Some ( "3.11.3" ) ) ,
1648
- ( "python39" , Some ( "39" ) ) , // Still a valid executable, although likely a typo
1677
+ ( "python39" , Some ( "39" ) ) ,
1649
1678
] ;
1650
1679
assert_cases (
1651
1680
& valid_cases,
@@ -1657,13 +1686,20 @@ mod tests {
1657
1686
#[ test]
1658
1687
fn invalid_python_executable_version ( ) {
1659
1688
let invalid_cases = [
1660
- ( "python-foo" , None ) ,
1661
- ( "python3abc" , None ) ,
1662
- ( "python3.12b3" , None ) ,
1663
- ( "pyth0n3" , None ) ,
1689
+ // Empty string
1664
1690
( "" , None ) ,
1691
+ ( "python" , None ) , // No version specified
1692
+ // Case-sensitive python prefix
1665
1693
( "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 ) ,
1667
1703
] ;
1668
1704
assert_cases (
1669
1705
& invalid_cases,
@@ -1674,30 +1710,40 @@ mod tests {
1674
1710
1675
1711
#[ test]
1676
1712
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) ;
1679
1721
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}"
1682
1730
) ;
1683
1731
}
1684
1732
}
1685
1733
1686
1734
#[ test]
1687
1735
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
1697
1743
] ;
1698
1744
for version in invalid_cases {
1699
1745
assert ! (
1700
- validate_python_version ( version) . is_err( ) ,
1746
+ parse_valid_python_version ( version) . is_err( ) ,
1701
1747
"Expected version `{version}` to be invalid"
1702
1748
) ;
1703
1749
}
0 commit comments