Support pre-releases in Python version requests - command --python <major.minor.pre-release> (#7335)

## Summary

This PR adds support to include Python pre-releases when requesting
versions.
Check out the docs for commands that support the `Python` option: 
```text
--python, -p python
The Python interpreter to use for the virtual environment.
```
At least the following scenarios are supported:
```bash
3.13.0a1
3.13b2
3.13rc4
313rc1
```

## Test Plan

I added a basic unit test to `uv/crates/uv-python/src/discovery.rs`. I
could have added more, but I have not discovered any relevant places.

CI passes

Note: I was unable to execute the entire test set locally. There were at
least some timeout issues (some tests took over 60 seconds).

========== output ===========
beta version
```bash
cargo run -- venv --python 3.13.0b3                                                                                                           ░▒▓ 94% 󰁹
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.20s
     Running `target/debug/uv venv --python 3.13.0b3`
Using Python 3.13.0b3 interpreter at: /home/mikko/.pyenv/versions/3.13.0b3/bin/python3
Creating virtualenv at: .venv
Activate with: source .venv/bin/activate
````
release candidate
```bash
 cargo run -- venv --python 3.13.0rc2                                                                                                          ░▒▓ 94% 󰁹
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.83s
     Running `target/debug/uv venv --python 3.13.0rc2`
Using Python 3.13.0rc2 interpreter at: /home/mikko/.pyenv/versions/3.13.0rc2/bin/python3
Creating virtualenv at: .venv
Activate with: source .venv/bin/activate
```

```bash
cargo run -- venv --python 313rc2                                                                                                             ░▒▓ 94% 󰁹
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/uv venv --python 313rc2`
Using Python 3.13.0rc2 interpreter at: /home/mikko/.pyenv/versions/3.13.0rc2/bin/python3
Creating virtualenv at: .venv
Activate with: source .venv/bin/activate
```

---------

Co-authored-by: Zanie Blue <contact@zanie.dev>
This commit is contained in:
Mikko Leppänen 2024-09-17 17:46:46 +03:00 committed by GitHub
parent 2cb3acdb1f
commit bb0ffa32e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -3,14 +3,14 @@ use regex::Regex;
use same_file::is_same_file;
use std::borrow::Cow;
use std::env::consts::EXE_SUFFIX;
use std::fmt::{self, Formatter};
use std::fmt::{self, Debug, Formatter};
use std::{env, io, iter};
use std::{path::Path, path::PathBuf, str::FromStr};
use thiserror::Error;
use tracing::{debug, instrument, trace};
use which::{which, which_all};
use pep440_rs::{Version, VersionSpecifier, VersionSpecifiers};
use pep440_rs::{Prerelease, Version, VersionSpecifier, VersionSpecifiers};
use uv_cache::Cache;
use uv_fs::Simplified;
use uv_warnings::warn_user_once;
@ -136,6 +136,7 @@ pub enum VersionRequest {
Major(u8),
MajorMinor(u8, u8),
MajorMinorPatch(u8, u8, u8),
MajorMinorPrerelease(u8, u8, Prerelease),
Range(VersionSpecifiers),
}
@ -532,9 +533,9 @@ fn find_all_minor(
.collect::<Vec<_>>();
Either::Left(all_minors.into_iter())
}
VersionRequest::MajorMinor(_, _) | VersionRequest::MajorMinorPatch(_, _, _) => {
Either::Right(iter::empty())
}
VersionRequest::MajorMinor(_, _)
| VersionRequest::MajorMinorPatch(_, _, _)
| VersionRequest::MajorMinorPrerelease(_, _, _) => Either::Right(iter::empty()),
}
}
@ -1468,6 +1469,14 @@ impl VersionRequest {
Some(Cow::Owned(format!("python{major}{extension}"))),
Some(python),
],
Self::MajorMinorPrerelease(major, minor, prerelease) => [
Some(Cow::Owned(format!(
"python{major}.{minor}{prerelease}{extension}",
))),
Some(Cow::Owned(format!("python{major}{extension}"))),
Some(python),
None,
],
}
}
@ -1511,6 +1520,14 @@ impl VersionRequest {
Some(Cow::Owned(format!("{name}{major}{extension}"))),
Some(python),
],
Self::MajorMinorPrerelease(major, minor, prerelease) => [
Some(Cow::Owned(format!(
"{name}{major}.{minor}{prerelease}{extension}",
))),
Some(Cow::Owned(format!("{name}{major}{extension}"))),
Some(python),
None,
],
}
})
.chain(self.default_names())
@ -1541,6 +1558,13 @@ impl VersionRequest {
));
}
}
Self::MajorMinorPrerelease(major, minor, prerelease) => {
if (*major, *minor) < (3, 7) {
return Err(format!(
"Python <3.7 is not supported but {major}.{minor}{prerelease} was requested."
));
}
}
// TODO(zanieb): We could do some checking here to see if the range can be satisfied
Self::Range(_) => (),
}
@ -1567,6 +1591,17 @@ impl VersionRequest {
let version = interpreter.python_version().only_release();
specifiers.contains(&version)
}
Self::MajorMinorPrerelease(major, minor, prerelease) => {
let version = interpreter.python_version();
let Some(interpreter_prerelease) = version.pre() else {
return false;
};
(
interpreter.python_major(),
interpreter.python_minor(),
interpreter_prerelease,
) == (*major, *minor, *prerelease)
}
}
}
@ -1582,6 +1617,10 @@ impl VersionRequest {
== (*major, *minor, Some(*patch))
}
Self::Range(specifiers) => specifiers.contains(&version.version),
Self::MajorMinorPrerelease(major, minor, prerelease) => {
(version.major(), version.minor(), version.pre())
== (*major, *minor, Some(*prerelease))
}
}
}
@ -1598,6 +1637,9 @@ impl VersionRequest {
Self::Range(specifiers) => {
specifiers.contains(&Version::new([u64::from(major), u64::from(minor)]))
}
Self::MajorMinorPrerelease(self_major, self_minor, _) => {
(*self_major, *self_minor) == (major, minor)
}
}
}
@ -1616,6 +1658,10 @@ impl VersionRequest {
u64::from(minor),
u64::from(patch),
])),
Self::MajorMinorPrerelease(self_major, self_minor, _) => {
// Pre-releases of Python versions are always for the zero patch version
(*self_major, *self_minor, 0) == (major, minor, patch)
}
}
}
@ -1626,6 +1672,7 @@ impl VersionRequest {
Self::Major(..) => false,
Self::MajorMinor(..) => false,
Self::MajorMinorPatch(..) => true,
Self::MajorMinorPrerelease(..) => false,
Self::Range(_) => false,
}
}
@ -1640,6 +1687,9 @@ impl VersionRequest {
Self::Major(major) => Self::Major(major),
Self::MajorMinor(major, minor) => Self::MajorMinor(major, minor),
Self::MajorMinorPatch(major, minor, _) => Self::MajorMinor(major, minor),
Self::MajorMinorPrerelease(major, minor, prerelease) => {
Self::MajorMinorPrerelease(major, minor, prerelease)
}
Self::Range(_) => self,
}
}
@ -1651,6 +1701,7 @@ impl VersionRequest {
Self::Major(_) => true,
Self::MajorMinor(..) => true,
Self::MajorMinorPatch(..) => true,
Self::MajorMinorPrerelease(..) => true,
Self::Range(specifiers) => specifiers.iter().any(VersionSpecifier::any_prerelease),
}
}
@ -1660,50 +1711,68 @@ impl FromStr for VersionRequest {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
fn parse_nosep(s: &str) -> Option<VersionRequest> {
let mut chars = s.chars();
let major = chars.next()?.to_digit(10)?.try_into().ok()?;
if chars.as_str().is_empty() {
return Some(VersionRequest::Major(major));
}
let minor = chars.as_str().parse::<u8>().ok()?;
Some(VersionRequest::MajorMinor(major, minor))
let Ok(version) = Version::from_str(s) else {
return parse_version_specifiers_request(s);
};
// Split the release component if it uses the wheel tag format (e.g., `38`)
let version = split_wheel_tag_release_version(version);
// We dont allow post and dev versions here
if version.post().is_some() || version.dev().is_some() {
return Err(Error::InvalidVersionRequest(s.to_string()));
}
// e.g. `3`, `38`, `312`
if let Some(request) = parse_nosep(s) {
Ok(request)
}
// e.g. `3.12.1`
else if let Ok(versions) = s
.splitn(3, '.')
.map(str::parse::<u8>)
.collect::<Result<Vec<_>, _>>()
{
let selector = match versions.as_slice() {
// e.g. `3`
[major] => VersionRequest::Major(*major),
// e.g. `3.10`
[major, minor] => VersionRequest::MajorMinor(*major, *minor),
// e.g. `3.10.4`
[major, minor, patch] => VersionRequest::MajorMinorPatch(*major, *minor, *patch),
_ => unreachable!(),
};
// Cast the release components into u8s since that's what we use in `VersionRequest`
let Ok(release) = try_into_u8_slice(version.release()) else {
return Err(Error::InvalidVersionRequest(s.to_string()));
};
Ok(selector)
let prerelease = version.pre();
// e.g. `>=3.12.1,<3.12`
} else if let Ok(specifiers) = VersionSpecifiers::from_str(s) {
if specifiers.is_empty() {
return Err(Error::InvalidVersionRequest(s.to_string()));
match release.as_slice() {
// e.g. `3
[major] => {
// Prereleases are not allowed here, e.g., `3rc1` doesn't make sense
if prerelease.is_some() {
return Err(Error::InvalidVersionRequest(s.to_string()));
}
Ok(Self::Major(*major))
}
Ok(Self::Range(specifiers))
} else {
Err(Error::InvalidVersionRequest(s.to_string()))
// e.g. `3.12` or `312` or `3.13rc1`
[major, minor] => {
if let Some(prerelease) = prerelease {
return Ok(Self::MajorMinorPrerelease(*major, *minor, prerelease));
}
Ok(Self::MajorMinor(*major, *minor))
}
// e.g. `3.12.1` or `3.13.0rc1`
[major, minor, patch] => {
if let Some(prerelease) = prerelease {
// Prereleases are only allowed for the first patch version, e.g, 3.12.2rc1
// isn't a proper Python release
if *patch != 0 {
return Err(Error::InvalidVersionRequest(s.to_string()));
}
return Ok(Self::MajorMinorPrerelease(*major, *minor, prerelease));
}
Ok(Self::MajorMinorPatch(*major, *minor, *patch))
}
_ => Err(Error::InvalidVersionRequest(s.to_string())),
}
}
}
fn parse_version_specifiers_request(s: &str) -> Result<VersionRequest, Error> {
let Ok(specifiers) = VersionSpecifiers::from_str(s) else {
return Err(Error::InvalidVersionRequest(s.to_string()));
};
if specifiers.is_empty() {
return Err(Error::InvalidVersionRequest(s.to_string()));
}
Ok(VersionRequest::Range(specifiers))
}
impl From<&PythonVersion> for VersionRequest {
fn from(version: &PythonVersion) -> Self {
Self::from_str(&version.string)
@ -1720,6 +1789,9 @@ impl fmt::Display for VersionRequest {
Self::MajorMinorPatch(major, minor, patch) => {
write!(f, "{major}.{minor}.{patch}")
}
Self::MajorMinorPrerelease(major, minor, prerelease) => {
write!(f, "{major}.{minor}{prerelease}")
}
Self::Range(specifiers) => write!(f, "{specifiers}"),
}
}
@ -1836,11 +1908,47 @@ fn conjunction(items: &[&str]) -> String {
}
}
fn try_into_u8_slice(release: &[u64]) -> Result<Vec<u8>, std::num::TryFromIntError> {
release
.iter()
.map(|x| match (*x).try_into() {
Ok(x) => Ok(x),
Err(e) => Err(e),
})
.collect()
}
/// Convert a wheel tag formatted version (e.g., `38`) to multiple components (e.g., `3.8`).
///
/// The major version is always assumed to be a single digit 0-9. The minor version is all of
/// the following content.
///
/// If not a wheel tag formatted version, the input is returned unchanged.
fn split_wheel_tag_release_version(version: Version) -> Version {
let release = version.release();
if release.len() != 1 {
return version;
}
let release = release[0].to_string();
let mut chars = release.chars();
let Some(major) = chars.next().and_then(|c| c.to_digit(10)) else {
return version;
};
let Ok(minor) = chars.as_str().parse::<u32>() else {
return version;
};
version.with_release([u64::from(major), u64::from(minor)])
}
#[cfg(test)]
mod tests {
use std::{path::PathBuf, str::FromStr};
use assert_fs::{prelude::*, TempDir};
use pep440_rs::{Prerelease, PrereleaseKind};
use test_log::test;
use crate::{
@ -1866,9 +1974,33 @@ mod tests {
PythonRequest::Version(VersionRequest::from_str(">=3.12,<3.13").unwrap())
);
assert_eq!(
PythonRequest::parse("foo"),
PythonRequest::ExecutableName("foo".to_string())
PythonRequest::parse(">=3.12,<3.13"),
PythonRequest::Version(VersionRequest::from_str(">=3.12,<3.13").unwrap())
);
assert_eq!(
PythonRequest::parse("3.13.0a1"),
PythonRequest::Version(VersionRequest::from_str("3.13.0a1").unwrap())
);
assert_eq!(
PythonRequest::parse("3.13.0b5"),
PythonRequest::Version(VersionRequest::from_str("3.13.0b5").unwrap())
);
assert_eq!(
PythonRequest::parse("3.13.0rc1"),
PythonRequest::Version(VersionRequest::from_str("3.13.0rc1").unwrap())
);
assert_eq!(
PythonRequest::parse("3.13.1rc1"),
PythonRequest::ExecutableName("3.13.1rc1".to_string()),
"Pre-release version requests require a patch version of zero"
);
assert_eq!(
PythonRequest::parse("3rc1"),
PythonRequest::ExecutableName("3rc1".to_string()),
"Pre-release version requests require a minor version"
);
assert_eq!(
PythonRequest::parse("cpython"),
PythonRequest::Implementation(ImplementationName::CPython)
@ -2005,6 +2137,31 @@ mod tests {
.to_canonical_string(),
">=3.12, <3.13"
);
assert_eq!(
PythonRequest::Version(VersionRequest::from_str("3.13.0a1").unwrap())
.to_canonical_string(),
"3.13a1"
);
assert_eq!(
PythonRequest::Version(VersionRequest::from_str("3.13.0b5").unwrap())
.to_canonical_string(),
"3.13b5"
);
assert_eq!(
PythonRequest::Version(VersionRequest::from_str("3.13.0rc1").unwrap())
.to_canonical_string(),
"3.13rc1"
);
assert_eq!(
PythonRequest::Version(VersionRequest::from_str("313rc4").unwrap())
.to_canonical_string(),
"3.13rc4"
);
assert_eq!(
PythonRequest::ExecutableName("foo".to_string()).to_canonical_string(),
"foo"
@ -2101,6 +2258,64 @@ mod tests {
VersionRequest::from_str("3100").unwrap(),
VersionRequest::MajorMinor(3, 100)
);
assert_eq!(
VersionRequest::from_str("3.13a1").unwrap(),
VersionRequest::MajorMinorPrerelease(
3,
13,
Prerelease {
kind: PrereleaseKind::Alpha,
number: 1
}
)
);
assert_eq!(
VersionRequest::from_str("313b1").unwrap(),
VersionRequest::MajorMinorPrerelease(
3,
13,
Prerelease {
kind: PrereleaseKind::Beta,
number: 1
}
)
);
assert_eq!(
VersionRequest::from_str("3.13.0b2").unwrap(),
VersionRequest::MajorMinorPrerelease(
3,
13,
Prerelease {
kind: PrereleaseKind::Beta,
number: 2
}
)
);
assert_eq!(
VersionRequest::from_str("3.13.0rc3").unwrap(),
VersionRequest::MajorMinorPrerelease(
3,
13,
Prerelease {
kind: PrereleaseKind::Rc,
number: 3
}
)
);
assert!(
matches!(
VersionRequest::from_str("3rc1"),
Err(Error::InvalidVersionRequest(_))
),
"Pre-release version requests require a minor version"
);
assert!(
matches!(
VersionRequest::from_str("3.13.2rc1"),
Err(Error::InvalidVersionRequest(_))
),
"Pre-release version requests require a patch version of zero"
);
assert!(
// Test for overflow
matches!(