Skip to content

Commit d8657fc

Browse files
committed
allow running non-default Python interpreters directly via uvx
Previously if you wanted to run e.g. PyPy via `uvx`, you had to spell it like `uvx -p pypy python`. Now we share (some of) the PythonRequest::parse machinery to handle the executable, so all of the following examples work: - uvx python - uvx python3.8 - uvx 38 - uvx pypy - uvx [email protected] - uvx graalpy - uvx [email protected] The `python` (and on Windows only, `pythonw`) special cases are retained, which normally aren't allowed values of `-p`/`--python`. However, the following examples deliberately *don't* work. Instead we interpret these arguments as package names. (The `pp` package exists, and we try to run it, but it fails to build.) - uvx 38 - uvp pp - uvp pp38 Closes #13536.
1 parent 0ddcc19 commit d8657fc

5 files changed

Lines changed: 436 additions & 218 deletions

File tree

crates/uv-python/src/discovery.rs

Lines changed: 150 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1388,55 +1388,38 @@ impl PythonVariant {
13881388
impl 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
}

crates/uv/src/commands/tool/install.rs

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ pub(crate) async fn install(
9999
.allow_insecure_host(network_settings.allow_insecure_host.clone());
100100

101101
// Parse the input requirement.
102-
let request = ToolRequest::parse(&package, from.as_deref());
102+
let request = ToolRequest::parse(&package, from.as_deref())?;
103103

104104
// If the user passed, e.g., `ruff@latest`, refresh the cache.
105105
let cache = if request.is_latest() {
@@ -109,9 +109,12 @@ pub(crate) async fn install(
109109
};
110110

111111
// Resolve the `--from` requirement.
112-
let from = match &request.target {
112+
let from = match &request {
113113
// Ex) `ruff`
114-
Target::Unspecified(from) => {
114+
ToolRequest::Package {
115+
executable,
116+
target: Target::Unspecified(from),
117+
} => {
115118
let source = if editable {
116119
RequirementsSource::from_editable(from)?
117120
} else {
@@ -122,7 +125,7 @@ pub(crate) async fn install(
122125
.requirements;
123126

124127
// If the user provided an executable name, verify that it matches the `--from` requirement.
125-
let executable = if let Some(executable) = request.executable {
128+
let executable = if let Some(executable) = executable {
126129
let Ok(executable) = PackageName::from_str(executable) else {
127130
bail!(
128131
"Package requirement (`{from}`) provided with `--from` conflicts with install request (`{executable}`)",
@@ -165,7 +168,10 @@ pub(crate) async fn install(
165168
requirement
166169
}
167170
168-
Target::Version(.., name, extras, version) => {
171+
ToolRequest::Package {
172+
target: Target::Version(.., name, extras, version),
173+
..
174+
} => {
169175
if editable {
170176
bail!("`--editable` is only supported for local packages");
171177
}
@@ -186,7 +192,10 @@ pub(crate) async fn install(
186192
}
187193
}
188194
// Ex) `ruff@latest`
189-
Target::Latest(.., name, extras) => {
195+
ToolRequest::Package {
196+
target: Target::Latest(.., name, extras),
197+
..
198+
} => {
190199
if editable {
191200
bail!("`--editable` is only supported for local packages");
192201
}
@@ -204,16 +213,16 @@ pub(crate) async fn install(
204213
origin: None,
205214
}
206215
}
216+
// Ex) `python`
217+
ToolRequest::Python(..) => {
218+
return Err(anyhow::anyhow!(
219+
"Cannot install Python with `{}`. Did you mean to use `{}`?",
220+
"uv tool install".cyan(),
221+
"uv python install".cyan(),
222+
));
223+
}
207224
};
208225

209-
if from.name.as_str().eq_ignore_ascii_case("python") {
210-
return Err(anyhow::anyhow!(
211-
"Cannot install Python with `{}`. Did you mean to use `{}`?",
212-
"uv tool install".cyan(),
213-
"uv python install".cyan(),
214-
));
215-
}
216-
217226
// If the user passed, e.g., `ruff@latest`, we need to mark it as upgradable.
218227
let settings = if request.is_latest() {
219228
ResolverInstallerSettings {

0 commit comments

Comments
 (0)