From bb0ffa32e4e78bb54af4347c2412114090b09ebe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikko=20Lepp=C3=A4nen?= Date: Tue, 17 Sep 2024 17:46:46 +0300 Subject: [PATCH] Support pre-releases in Python version requests - `command --python ` (#7335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- crates/uv-python/src/discovery.rs | 299 +++++++++++++++++++++++++----- 1 file changed, 257 insertions(+), 42 deletions(-) diff --git a/crates/uv-python/src/discovery.rs b/crates/uv-python/src/discovery.rs index 9715f0dec..cda59cf93 100644 --- a/crates/uv-python/src/discovery.rs +++ b/crates/uv-python/src/discovery.rs @@ -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::>(); 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 { - fn parse_nosep(s: &str) -> Option { - 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::().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::) - .collect::, _>>() - { - 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 { + 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, 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::() 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!(