@@ -1388,55 +1388,38 @@ impl PythonVariant {
13881388impl PythonRequest {
13891389 /// Create a request from a string.
13901390 ///
1391- /// This cannot fail, which means weird inputs will be parsed as [`PythonRequest::File`] or [`PythonRequest::ExecutableName`].
1391+ /// This cannot fail, which means weird inputs will be parsed as [`PythonRequest::File`] or
1392+ /// [`PythonRequest::ExecutableName`].
1393+ ///
1394+ /// This is intended for parsing the argument to the `--python` flag. See also
1395+ /// `parse_tool_executable` below.
13921396 pub fn parse ( value : & str ) -> Self {
1397+ let lowercase_value = & value. to_ascii_lowercase ( ) ;
1398+
13931399 // Literals, e.g. `any` or `default`
1394- if value . eq_ignore_ascii_case ( "any" ) {
1400+ if lowercase_value == "any" {
13951401 return Self :: Any ;
13961402 }
1397- if value . eq_ignore_ascii_case ( "default" ) {
1403+ if lowercase_value == "default" {
13981404 return Self :: Default ;
13991405 }
14001406
1401- // e.g. `3.12.1`, `312`, or `>=3.12`
1402- if let Ok ( version) = VersionRequest :: from_str ( value) {
1403- return Self :: Version ( version) ;
1404- }
1405- // e.g. `python3.12.1`
1406- if let Some ( remainder) = value. strip_prefix ( "python" ) {
1407- if let Ok ( version) = VersionRequest :: from_str ( remainder) {
1408- return Self :: Version ( version) ;
1409- }
1410- }
1411- 1412- if let Some ( ( first, second) ) = value. split_once ( '@' ) {
1413- if let Ok ( implementation) = ImplementationName :: from_str ( first) {
1414- if let Ok ( version) = VersionRequest :: from_str ( second) {
1415- return Self :: ImplementationVersion ( implementation, version) ;
1416- }
1417- }
1418- }
1419- for implementation in
1420- ImplementationName :: long_names ( ) . chain ( ImplementationName :: short_names ( ) )
1421- {
1422- if let Some ( remainder) = value. to_ascii_lowercase ( ) . strip_prefix ( implementation) {
1423- // e.g. `pypy`
1424- if remainder. is_empty ( ) {
1425- return Self :: Implementation (
1426- // Safety: The name matched the possible names above
1427- ImplementationName :: from_str ( implementation) . unwrap ( ) ,
1428- ) ;
1429- }
1430- // e.g. `pypy3.12` or `pp312`
1431- if let Ok ( version) = VersionRequest :: from_str ( remainder) {
1432- return Self :: ImplementationVersion (
1433- // Safety: The name matched the possible names above
1434- ImplementationName :: from_str ( implementation) . unwrap ( ) ,
1435- version,
1436- ) ;
1437- }
1438- }
1407+ let abstract_version_prefixes = [
1408+ "python" , // the prefix of e.g. `python312`
1409+ "" , // the empty prefix of bare versions, e.g. `312`
1410+ ] ;
1411+ let all_implementation_names =
1412+ ImplementationName :: long_names ( ) . chain ( ImplementationName :: short_names ( ) ) ;
1413+ // Abstract versions like `python@312`, `python312`, or `312`, plus implementations and
1414+ // implementation versions like `pypy`, `pypy@312` or `pypy312`.
1415+ if let Ok ( Some ( request) ) = Self :: parse_versions_and_implementations (
1416+ abstract_version_prefixes,
1417+ all_implementation_names,
1418+ lowercase_value,
1419+ ) {
1420+ return request;
14391421 }
1422+
14401423 let value_as_path = PathBuf :: from ( value) ;
14411424 // e.g. /path/to/.venv
14421425 if value_as_path. is_dir ( ) {
@@ -1447,7 +1430,7 @@ impl PythonRequest {
14471430 return Self :: File ( value_as_path) ;
14481431 }
14491432
1450- // e.g. path/to/python on Windows, where path/to/python is the true path
1433+ // e.g. path/to/python on Windows, where path/to/python.exe is the true path
14511434 #[ cfg( windows) ]
14521435 if value_as_path. extension ( ) . is_none ( ) {
14531436 let value_as_path = value_as_path. with_extension ( EXE_SUFFIX ) ;
@@ -1490,6 +1473,101 @@ impl PythonRequest {
14901473 Self :: ExecutableName ( value. to_string ( ) )
14911474 }
14921475
1476+ /// Try to parse a tool executable as a Python version, e.g. `uvx [executable]`.
1477+ ///
1478+ /// The `PythonRequest::parse` constructor above is intended for the `--python` flag, where the
1479+ /// value is unambiguously a Python version. This alternate constructor is intended for `uvx`,
1480+ /// where the executable might be a Python version or a package name. There are several
1481+ /// differences in behavior:
1482+ /// - This only supports long names, including e.g. `pypy39` but **not** `pp39` or `39`.
1483+ /// - On Windows only, this allows `pythonw` as an alias for `python`.
1484+ /// - This allows `python` by itself (and on Windows, `pythonw`) as an alias for `default`.
1485+ ///
1486+ /// This only returns `Err` if `@` is used, and the prefix is a match, but the version is
1487+ /// invalid. Otherwise, if no match is found, it returns `Ok(None)`.
1488+ pub fn try_parse_tool_executable ( value : & str ) -> Result < Option < PythonRequest > , Error > {
1489+ let lowercase_value = & value. to_ascii_lowercase ( ) ;
1490+ // Omitting the empty string from these lists excludes bare versions like "39".
1491+ let abstract_version_prefixes = if cfg ! ( windows) {
1492+ & [ "python" , "pythonw" ] [ ..]
1493+ } else {
1494+ & [ "python" ] [ ..]
1495+ } ;
1496+ // e.g. just `python`
1497+ if abstract_version_prefixes. contains ( & lowercase_value. as_str ( ) ) {
1498+ return Ok ( Some ( Self :: Default ) ) ;
1499+ }
1500+ Self :: parse_versions_and_implementations (
1501+ abstract_version_prefixes. iter ( ) . copied ( ) ,
1502+ ImplementationName :: long_names ( ) ,
1503+ lowercase_value,
1504+ )
1505+ }
1506+
1507+ // This only returns Err() if @ is used, and the prefix is a match, but the version is invalid.
1508+ // Otherwise, if no match is found, it returns Ok(None).
1509+ fn parse_versions_and_implementations < ' a > (
1510+ // typically "python", possibly also "pythonw" or "" (for bare versions)
1511+ abstract_version_prefixes : impl IntoIterator < Item = & ' a str > ,
1512+ // expected to be either long_names() or all names
1513+ implementation_names : impl IntoIterator < Item = & ' a str > ,
1514+ // the string to parse, case-insensitive
1515+ lowercase_value : & str ,
1516+ ) -> Result < Option < PythonRequest > , Error > {
1517+ for prefix in abstract_version_prefixes {
1518+ if let Some ( version_request) =
1519+ Self :: try_split_prefix_and_version ( prefix, lowercase_value) ?
1520+ {
1521+ // e.g. `python39` or `python@39`
1522+ // Note that e.g. `python` gets handled elsewhere, if at all. (It's currently
1523+ // allowed in tool executables but not in --python flags.)
1524+ return Ok ( Some ( Self :: Version ( version_request) ) ) ;
1525+ }
1526+ }
1527+ for implementation in implementation_names {
1528+ if lowercase_value == implementation {
1529+ return Ok ( Some ( Self :: Implementation (
1530+ // e.g. `pypy`
1531+ // Safety: The name matched the possible names above
1532+ ImplementationName :: from_str ( implementation) . unwrap ( ) ,
1533+ ) ) ) ;
1534+ }
1535+ if let Some ( version_request) =
1536+ Self :: try_split_prefix_and_version ( implementation, lowercase_value) ?
1537+ {
1538+ // e.g. `pypy39`
1539+ return Ok ( Some ( Self :: ImplementationVersion (
1540+ // Safety: The name matched the possible names above
1541+ ImplementationName :: from_str ( implementation) . unwrap ( ) ,
1542+ version_request,
1543+ ) ) ) ;
1544+ }
1545+ }
1546+ Ok ( None )
1547+ }
1548+
1549+ // This only returns Err() if @ is used, and the prefix is a match, but the version is invalid.
1550+ fn try_split_prefix_and_version (
1551+ prefix : & str ,
1552+ lowercase_value : & str ,
1553+ ) -> Result < Option < VersionRequest > , Error > {
1554+ let Some ( rest) = lowercase_value. strip_prefix ( prefix) else {
1555+ return Ok ( None ) ;
1556+ } ;
1557+ // Just the prefix by itself (e.g. "python") is handled elsewhere.
1558+ if rest. is_empty ( ) {
1559+ return Ok ( None ) ;
1560+ }
1561+ // The @ separator is optional. If it's present, the right half must be a version, and
1562+ // parsing errors are raised to the caller.
1563+ if let Some ( after_at) = rest. strip_prefix ( '@' ) {
1564+ return after_at. parse ( ) . map ( Some ) ;
1565+ }
1566+ // The @ was not present, so if the version fails to parse just return Ok(None). For
1567+ // example, python3stuff.
1568+ Ok ( rest. parse ( ) . ok ( ) )
1569+ }
1570+
14931571 /// Check if a given interpreter satisfies the interpreter request.
14941572 pub fn satisfied ( & self , interpreter : & Interpreter , cache : & Cache ) -> bool {
14951573 /// Returns `true` if the two paths refer to the same interpreter executable.
@@ -2360,6 +2438,12 @@ impl FromStr for VersionRequest {
23602438 type Err = Error ;
23612439
23622440 fn from_str ( s : & str ) -> Result < Self , Self :: Err > {
2441+ // Stripping the 't' suffix produces awkward error messages if the user tries a version
2442+ // like "latest". HACK: If the version is all letters, don't even try to parse it further.
2443+ if s. as_bytes ( ) . iter ( ) . all ( u8:: is_ascii_alphabetic) {
2444+ return Err ( Error :: InvalidVersionRequest ( s. to_string ( ) ) ) ;
2445+ }
2446+
23632447 // Check if the version request is for a free-threaded Python version
23642448 let ( s, variant) = s
23652449 . strip_suffix ( 't' )
@@ -3321,4 +3405,28 @@ mod tests {
33213405 & [ "python3.13rc2" , "python3.13" , "python3" , "python" ] ,
33223406 ) ;
33233407 }
3408+
3409+ #[ test]
3410+ fn test_try_split_prefix_and_version ( ) {
3411+ assert ! ( matches!(
3412+ PythonRequest :: try_split_prefix_and_version( "prefix" , "prefix" ) ,
3413+ Ok ( None ) ,
3414+ ) ) ;
3415+ assert ! ( matches!(
3416+ PythonRequest :: try_split_prefix_and_version( "prefix" , "prefix3" ) ,
3417+ Ok ( Some ( _) ) ,
3418+ ) ) ;
3419+ assert ! ( matches!(
3420+ PythonRequest :: try_split_prefix_and_version( "prefix" , "prefix@3" ) ,
3421+ Ok ( Some ( _) ) ,
3422+ ) ) ;
3423+ assert ! ( matches!(
3424+ PythonRequest :: try_split_prefix_and_version( "prefix" , "prefix3notaversion" ) ,
3425+ Ok ( None ) ,
3426+ ) ) ;
3427+ // Version parsing errors are only raised if @ is present.
3428+ assert ! (
3429+ PythonRequest :: try_split_prefix_and_version( "prefix" , "prefix@3notaversion" ) . is_err( )
3430+ ) ;
3431+ }
33243432}
0 commit comments