Skip to main content

pro_core/python/
versions.rs

1//! Python version types and parsing
2//!
3//! Handles Python version specifications (3.12, 3.11.8, etc.) and
4//! provides information about available versions from python-build-standalone.
5
6use std::cmp::Ordering;
7use std::fmt;
8use std::str::FromStr;
9
10use crate::{Error, Result};
11
12/// A Python version with major, minor, and optional patch
13#[derive(Debug, Clone, PartialEq, Eq, Hash)]
14pub struct PythonVersion {
15    pub major: u32,
16    pub minor: u32,
17    pub patch: Option<u32>,
18}
19
20impl PythonVersion {
21    /// Create a new Python version
22    pub fn new(major: u32, minor: u32, patch: Option<u32>) -> Self {
23        Self {
24            major,
25            minor,
26            patch,
27        }
28    }
29
30    /// Parse a version string like "3.12" or "3.12.1"
31    pub fn parse(s: &str) -> Result<Self> {
32        let parts: Vec<&str> = s.trim().split('.').collect();
33
34        match parts.len() {
35            2 => {
36                let major: u32 = parts[0]
37                    .parse()
38                    .map_err(|_| Error::InvalidVersion(format!("invalid major version: {}", s)))?;
39                let minor: u32 = parts[1]
40                    .parse()
41                    .map_err(|_| Error::InvalidVersion(format!("invalid minor version: {}", s)))?;
42                Ok(Self::new(major, minor, None))
43            }
44            3 => {
45                let major: u32 = parts[0]
46                    .parse()
47                    .map_err(|_| Error::InvalidVersion(format!("invalid major version: {}", s)))?;
48                let minor: u32 = parts[1]
49                    .parse()
50                    .map_err(|_| Error::InvalidVersion(format!("invalid minor version: {}", s)))?;
51                let patch: u32 = parts[2]
52                    .parse()
53                    .map_err(|_| Error::InvalidVersion(format!("invalid patch version: {}", s)))?;
54                Ok(Self::new(major, minor, Some(patch)))
55            }
56            _ => Err(Error::InvalidVersion(format!(
57                "invalid version format: {} (expected X.Y or X.Y.Z)",
58                s
59            ))),
60        }
61    }
62
63    /// Check if this version matches a specification (e.g., "3.12" matches "3.12.1")
64    pub fn matches(&self, spec: &PythonVersion) -> bool {
65        if self.major != spec.major || self.minor != spec.minor {
66            return false;
67        }
68
69        // If spec has no patch, any patch matches
70        // If spec has a patch, it must match exactly
71        match spec.patch {
72            None => true,
73            Some(p) => self.patch == Some(p),
74        }
75    }
76
77    /// Get the version string (e.g., "3.12.1" or "3.12")
78    pub fn to_string_full(&self) -> String {
79        match self.patch {
80            Some(p) => format!("{}.{}.{}", self.major, self.minor, p),
81            None => format!("{}.{}", self.major, self.minor),
82        }
83    }
84
85    /// Get the major.minor string (e.g., "3.12")
86    pub fn to_string_short(&self) -> String {
87        format!("{}.{}", self.major, self.minor)
88    }
89}
90
91impl fmt::Display for PythonVersion {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        match self.patch {
94            Some(p) => write!(f, "{}.{}.{}", self.major, self.minor, p),
95            None => write!(f, "{}.{}", self.major, self.minor),
96        }
97    }
98}
99
100impl FromStr for PythonVersion {
101    type Err = Error;
102
103    fn from_str(s: &str) -> Result<Self> {
104        Self::parse(s)
105    }
106}
107
108impl Ord for PythonVersion {
109    fn cmp(&self, other: &Self) -> Ordering {
110        match self.major.cmp(&other.major) {
111            Ordering::Equal => match self.minor.cmp(&other.minor) {
112                Ordering::Equal => {
113                    let self_patch = self.patch.unwrap_or(0);
114                    let other_patch = other.patch.unwrap_or(0);
115                    self_patch.cmp(&other_patch)
116                }
117                ord => ord,
118            },
119            ord => ord,
120        }
121    }
122}
123
124impl PartialOrd for PythonVersion {
125    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
126        Some(self.cmp(other))
127    }
128}
129
130/// Information about an available Python version
131#[derive(Debug, Clone)]
132pub struct AvailableVersion {
133    pub version: PythonVersion,
134    /// The release tag from python-build-standalone
135    pub release_tag: String,
136}
137
138impl AvailableVersion {
139    pub fn new(version: PythonVersion, release_tag: impl Into<String>) -> Self {
140        Self {
141            version,
142            release_tag: release_tag.into(),
143        }
144    }
145}
146
147/// Get a list of known available Python versions from python-build-standalone
148///
149/// This is a static list that should be updated periodically.
150/// For a more dynamic solution, we could fetch from GitHub releases API.
151pub fn available_versions() -> Vec<AvailableVersion> {
152    // These are the latest versions available from python-build-standalone
153    // as of January 2025
154    vec![
155        // Python 3.13
156        AvailableVersion::new(PythonVersion::new(3, 13, Some(1)), "20250115"),
157        AvailableVersion::new(PythonVersion::new(3, 13, Some(0)), "20241206"),
158        // Python 3.12
159        AvailableVersion::new(PythonVersion::new(3, 12, Some(8)), "20250115"),
160        AvailableVersion::new(PythonVersion::new(3, 12, Some(7)), "20241206"),
161        AvailableVersion::new(PythonVersion::new(3, 12, Some(6)), "20240909"),
162        AvailableVersion::new(PythonVersion::new(3, 12, Some(5)), "20240814"),
163        // Python 3.11
164        AvailableVersion::new(PythonVersion::new(3, 11, Some(11)), "20250115"),
165        AvailableVersion::new(PythonVersion::new(3, 11, Some(10)), "20241016"),
166        AvailableVersion::new(PythonVersion::new(3, 11, Some(9)), "20240814"),
167        // Python 3.10
168        AvailableVersion::new(PythonVersion::new(3, 10, Some(16)), "20250115"),
169        AvailableVersion::new(PythonVersion::new(3, 10, Some(15)), "20241016"),
170        AvailableVersion::new(PythonVersion::new(3, 10, Some(14)), "20240814"),
171        // Python 3.9
172        AvailableVersion::new(PythonVersion::new(3, 9, Some(21)), "20250115"),
173        AvailableVersion::new(PythonVersion::new(3, 9, Some(20)), "20241016"),
174    ]
175}
176
177/// Find the latest version matching a specification
178pub fn find_matching_version(spec: &PythonVersion) -> Option<AvailableVersion> {
179    available_versions()
180        .into_iter()
181        .filter(|v| v.version.matches(spec))
182        .max_by(|a, b| a.version.cmp(&b.version))
183}
184
185/// Get all versions for a specific major.minor
186pub fn get_versions_for_minor(major: u32, minor: u32) -> Vec<AvailableVersion> {
187    let spec = PythonVersion::new(major, minor, None);
188    available_versions()
189        .into_iter()
190        .filter(|v| v.version.matches(&spec))
191        .collect()
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn test_parse_short_version() {
200        let v = PythonVersion::parse("3.12").unwrap();
201        assert_eq!(v.major, 3);
202        assert_eq!(v.minor, 12);
203        assert_eq!(v.patch, None);
204    }
205
206    #[test]
207    fn test_parse_full_version() {
208        let v = PythonVersion::parse("3.12.1").unwrap();
209        assert_eq!(v.major, 3);
210        assert_eq!(v.minor, 12);
211        assert_eq!(v.patch, Some(1));
212    }
213
214    #[test]
215    fn test_parse_invalid() {
216        assert!(PythonVersion::parse("3").is_err());
217        assert!(PythonVersion::parse("3.12.1.2").is_err());
218        assert!(PythonVersion::parse("abc").is_err());
219    }
220
221    #[test]
222    fn test_matches() {
223        let spec = PythonVersion::new(3, 12, None);
224        let full = PythonVersion::new(3, 12, Some(1));
225
226        assert!(full.matches(&spec));
227        assert!(spec.matches(&spec));
228
229        let different = PythonVersion::new(3, 11, Some(1));
230        assert!(!different.matches(&spec));
231    }
232
233    #[test]
234    fn test_version_ordering() {
235        let v1 = PythonVersion::new(3, 11, Some(1));
236        let v2 = PythonVersion::new(3, 12, Some(0));
237        let v3 = PythonVersion::new(3, 12, Some(1));
238
239        assert!(v1 < v2);
240        assert!(v2 < v3);
241        assert!(v1 < v3);
242    }
243
244    #[test]
245    fn test_find_matching() {
246        let spec = PythonVersion::new(3, 12, None);
247        let found = find_matching_version(&spec);
248        assert!(found.is_some());
249        assert_eq!(found.unwrap().version.minor, 12);
250    }
251
252    #[test]
253    fn test_display() {
254        let v = PythonVersion::new(3, 12, Some(1));
255        assert_eq!(format!("{}", v), "3.12.1");
256
257        let v = PythonVersion::new(3, 12, None);
258        assert_eq!(format!("{}", v), "3.12");
259    }
260}