Skip to main content

use_python_version/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// Python major version component.
8#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
9pub struct PythonMajorVersion(u16);
10
11impl PythonMajorVersion {
12    /// Creates a non-zero Python major version component.
13    ///
14    /// # Errors
15    ///
16    /// Returns [`PythonVersionParseError::InvalidVersion`] when `value` is zero.
17    pub const fn new(value: u16) -> Result<Self, PythonVersionParseError> {
18        if value == 0 {
19            Err(PythonVersionParseError::InvalidVersion)
20        } else {
21            Ok(Self(value))
22        }
23    }
24
25    /// Returns the numeric component.
26    #[must_use]
27    pub const fn get(self) -> u16 {
28        self.0
29    }
30}
31
32/// Python minor version component.
33#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
34pub struct PythonMinorVersion(u16);
35
36impl PythonMinorVersion {
37    /// Creates a Python minor version component.
38    #[must_use]
39    pub const fn new(value: u16) -> Self {
40        Self(value)
41    }
42
43    /// Returns the numeric component.
44    #[must_use]
45    pub const fn get(self) -> u16 {
46        self.0
47    }
48}
49
50/// Python patch version component.
51#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
52pub struct PythonPatchVersion(u16);
53
54impl PythonPatchVersion {
55    /// Creates a Python patch version component.
56    #[must_use]
57    pub const fn new(value: u16) -> Self {
58        Self(value)
59    }
60
61    /// Returns the numeric component.
62    #[must_use]
63    pub const fn get(self) -> u16 {
64        self.0
65    }
66}
67
68/// Lightweight Python version metadata.
69#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
70pub struct PythonVersion {
71    major: PythonMajorVersion,
72    minor: Option<PythonMinorVersion>,
73    patch: Option<PythonPatchVersion>,
74    suffix: Option<String>,
75}
76
77impl PythonVersion {
78    /// Creates Python version metadata.
79    ///
80    /// # Errors
81    ///
82    /// Returns [`PythonVersionParseError::InvalidVersion`] when the major version is zero or
83    /// a patch component is present without a minor component.
84    pub fn new(
85        major: u16,
86        minor: Option<u16>,
87        patch: Option<u16>,
88    ) -> Result<Self, PythonVersionParseError> {
89        if minor.is_none() && patch.is_some() {
90            return Err(PythonVersionParseError::InvalidVersion);
91        }
92
93        Ok(Self {
94            major: PythonMajorVersion::new(major)?,
95            minor: minor.map(PythonMinorVersion::new),
96            patch: patch.map(PythonPatchVersion::new),
97            suffix: None,
98        })
99    }
100
101    /// Returns the major version number.
102    #[must_use]
103    pub const fn major(&self) -> u16 {
104        self.major.get()
105    }
106
107    /// Returns the optional minor version number.
108    #[must_use]
109    pub const fn minor(&self) -> Option<u16> {
110        match self.minor {
111            Some(value) => Some(value.get()),
112            None => None,
113        }
114    }
115
116    /// Returns the optional patch version number.
117    #[must_use]
118    pub const fn patch(&self) -> Option<u16> {
119        match self.patch {
120            Some(value) => Some(value.get()),
121            None => None,
122        }
123    }
124
125    /// Returns whether this version is in the Python 3 family.
126    #[must_use]
127    pub const fn is_python3(&self) -> bool {
128        self.major() == 3
129    }
130
131    /// Returns whether the parsed suffix looks like a prerelease marker.
132    #[must_use]
133    pub fn is_prerelease_like(&self) -> bool {
134        self.suffix.as_deref().is_some_and(|suffix| {
135            suffix
136                .chars()
137                .any(|character| character.is_ascii_alphabetic())
138        })
139    }
140
141    /// Returns the parsed non-numeric suffix when one was present.
142    #[must_use]
143    pub fn suffix(&self) -> Option<&str> {
144        self.suffix.as_deref()
145    }
146}
147
148impl fmt::Display for PythonVersion {
149    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
150        write!(formatter, "{}", self.major())?;
151        if let Some(minor) = self.minor() {
152            write!(formatter, ".{minor}")?;
153        }
154        if let Some(patch) = self.patch() {
155            write!(formatter, ".{patch}")?;
156        }
157        if let Some(suffix) = self.suffix() {
158            formatter.write_str(suffix)?;
159        }
160        Ok(())
161    }
162}
163
164impl FromStr for PythonVersion {
165    type Err = PythonVersionParseError;
166
167    fn from_str(input: &str) -> Result<Self, Self::Err> {
168        parse_python_version(input)
169    }
170}
171
172/// Python version family label.
173#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
174pub enum PythonVersionFamily {
175    Python2,
176    Python3,
177}
178
179impl PythonVersionFamily {
180    /// Returns the lowercase family label.
181    #[must_use]
182    pub const fn as_str(self) -> &'static str {
183        match self {
184            Self::Python2 => "python2",
185            Self::Python3 => "python3",
186        }
187    }
188}
189
190impl fmt::Display for PythonVersionFamily {
191    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
192        formatter.write_str(self.as_str())
193    }
194}
195
196impl FromStr for PythonVersionFamily {
197    type Err = PythonVersionParseError;
198
199    fn from_str(input: &str) -> Result<Self, Self::Err> {
200        match normalized_label(input)?.as_str() {
201            "python2" | "py2" | "2" => Ok(Self::Python2),
202            "python3" | "py3" | "3" => Ok(Self::Python3),
203            _ => Err(PythonVersionParseError::UnknownLabel),
204        }
205    }
206}
207
208/// Python implementation label.
209#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
210pub enum PythonImplementation {
211    CPython,
212    PyPy,
213    MicroPython,
214    GraalPy,
215    RustPython,
216}
217
218impl PythonImplementation {
219    /// Returns the normalized implementation label.
220    #[must_use]
221    pub const fn as_str(self) -> &'static str {
222        match self {
223            Self::CPython => "cpython",
224            Self::PyPy => "pypy",
225            Self::MicroPython => "micropython",
226            Self::GraalPy => "graalpy",
227            Self::RustPython => "rustpython",
228        }
229    }
230}
231
232impl fmt::Display for PythonImplementation {
233    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
234        formatter.write_str(self.as_str())
235    }
236}
237
238impl FromStr for PythonImplementation {
239    type Err = PythonVersionParseError;
240
241    fn from_str(input: &str) -> Result<Self, Self::Err> {
242        match normalized_label(input)?.as_str() {
243            "cpython" | "cp" => Ok(Self::CPython),
244            "pypy" | "pp" => Ok(Self::PyPy),
245            "micropython" => Ok(Self::MicroPython),
246            "graalpy" => Ok(Self::GraalPy),
247            "rustpython" => Ok(Self::RustPython),
248            _ => Err(PythonVersionParseError::UnknownLabel),
249        }
250    }
251}
252
253macro_rules! tag_newtype {
254    ($name:ident) => {
255        #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
256        pub struct $name(String);
257
258        impl $name {
259            /// Creates non-empty Python tag metadata.
260            ///
261            /// # Errors
262            ///
263            /// Returns [`PythonVersionParseError::Empty`] when `input` is empty after trimming.
264            pub fn new(input: &str) -> Result<Self, PythonVersionParseError> {
265                let trimmed = input.trim();
266                if trimmed.is_empty() {
267                    Err(PythonVersionParseError::Empty)
268                } else {
269                    Ok(Self(trimmed.to_string()))
270                }
271            }
272
273            /// Returns the stored tag text.
274            #[must_use]
275            pub fn as_str(&self) -> &str {
276                &self.0
277            }
278        }
279
280        impl fmt::Display for $name {
281            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
282                formatter.write_str(self.as_str())
283            }
284        }
285
286        impl FromStr for $name {
287            type Err = PythonVersionParseError;
288
289            fn from_str(input: &str) -> Result<Self, Self::Err> {
290                Self::new(input)
291            }
292        }
293
294        impl TryFrom<&str> for $name {
295            type Error = PythonVersionParseError;
296
297            fn try_from(value: &str) -> Result<Self, Self::Error> {
298                Self::new(value)
299            }
300        }
301    };
302}
303
304tag_newtype!(PythonCompatibilityTag);
305tag_newtype!(PythonAbiTag);
306tag_newtype!(PythonPlatformTag);
307
308/// Error returned while parsing Python version metadata.
309#[derive(Clone, Copy, Debug, Eq, PartialEq)]
310pub enum PythonVersionParseError {
311    Empty,
312    InvalidVersion,
313    UnknownLabel,
314}
315
316impl fmt::Display for PythonVersionParseError {
317    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
318        match self {
319            Self::Empty => formatter.write_str("Python version metadata cannot be empty"),
320            Self::InvalidVersion => formatter.write_str("invalid Python version"),
321            Self::UnknownLabel => formatter.write_str("unknown Python version metadata label"),
322        }
323    }
324}
325
326impl Error for PythonVersionParseError {}
327
328fn parse_python_version(input: &str) -> Result<PythonVersion, PythonVersionParseError> {
329    let mut text = input.trim();
330    if text.is_empty() {
331        return Err(PythonVersionParseError::Empty);
332    }
333    if text.len() >= 6 && text[..6].eq_ignore_ascii_case("python") {
334        text = text[6..].trim_start();
335    }
336    if let Some(stripped) = text.strip_prefix(['v', 'V']) {
337        text = stripped;
338    }
339
340    let mut parts = text.splitn(3, '.');
341    let Some(major_text) = parts.next() else {
342        return Err(PythonVersionParseError::InvalidVersion);
343    };
344    let (major, major_suffix) = parse_component_with_suffix(major_text)?;
345    if !major_suffix.is_empty() {
346        return PythonVersion::new(major, None, None).map(|mut version| {
347            version.suffix = Some(major_suffix.to_string());
348            version
349        });
350    }
351
352    let minor = match parts.next() {
353        Some(minor_text) => {
354            let (value, suffix) = parse_component_with_suffix(minor_text)?;
355            if suffix.is_empty() {
356                Some(value)
357            } else {
358                return PythonVersion::new(major, Some(value), None).map(|mut version| {
359                    version.suffix = Some(suffix.to_string());
360                    version
361                });
362            }
363        }
364        None => None,
365    };
366
367    let (patch, suffix) = match parts.next() {
368        Some(patch_text) => {
369            let (value, suffix) = parse_component_with_suffix(patch_text)?;
370            (Some(value), suffix)
371        }
372        None => (None, ""),
373    };
374
375    PythonVersion::new(major, minor, patch).map(|mut version| {
376        if !suffix.is_empty() {
377            version.suffix = Some(suffix.to_string());
378        }
379        version
380    })
381}
382
383fn parse_component_with_suffix(input: &str) -> Result<(u16, &str), PythonVersionParseError> {
384    if input.is_empty() {
385        return Err(PythonVersionParseError::InvalidVersion);
386    }
387    let digit_len = input
388        .char_indices()
389        .take_while(|(_, character)| character.is_ascii_digit())
390        .map(|(index, character)| index + character.len_utf8())
391        .last()
392        .ok_or(PythonVersionParseError::InvalidVersion)?;
393    let digits = &input[..digit_len];
394    let suffix = &input[digit_len..];
395    let value = digits
396        .parse::<u16>()
397        .map_err(|_| PythonVersionParseError::InvalidVersion)?;
398    Ok((value, suffix))
399}
400
401fn normalized_label(input: &str) -> Result<String, PythonVersionParseError> {
402    let trimmed = input.trim();
403    if trimmed.is_empty() {
404        Err(PythonVersionParseError::Empty)
405    } else {
406        Ok(trimmed.to_ascii_lowercase().replace(['-', '_', ' '], ""))
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::{
413        PythonAbiTag, PythonImplementation, PythonPlatformTag, PythonVersion, PythonVersionFamily,
414        PythonVersionParseError,
415    };
416
417    #[test]
418    fn parses_common_version_shapes() -> Result<(), PythonVersionParseError> {
419        let major_only: PythonVersion = "3".parse()?;
420        let minor: PythonVersion = "3.11".parse()?;
421        let patch: PythonVersion = "Python 3.12.1".parse()?;
422        let prefixed: PythonVersion = "v3.13.0".parse()?;
423
424        assert_eq!(major_only.major(), 3);
425        assert_eq!(minor.minor(), Some(11));
426        assert_eq!(patch.patch(), Some(1));
427        assert_eq!(prefixed.to_string(), "3.13.0");
428        assert!(prefixed.is_python3());
429        Ok(())
430    }
431
432    #[test]
433    fn parses_prerelease_like_suffixes() -> Result<(), PythonVersionParseError> {
434        let version: PythonVersion = "3.14.0rc1".parse()?;
435
436        assert!(version.is_prerelease_like());
437        assert_eq!(version.suffix(), Some("rc1"));
438        assert_eq!(version.to_string(), "3.14.0rc1");
439        Ok(())
440    }
441
442    #[test]
443    fn models_implementation_and_tags() -> Result<(), PythonVersionParseError> {
444        assert_eq!(
445            "CPython".parse::<PythonImplementation>()?,
446            PythonImplementation::CPython
447        );
448        assert_eq!(
449            "py3".parse::<PythonVersionFamily>()?,
450            PythonVersionFamily::Python3
451        );
452        assert_eq!(PythonVersionFamily::Python2.to_string(), "python2");
453        assert_eq!(PythonAbiTag::new("cp312")?.as_str(), "cp312");
454        assert_eq!(
455            PythonPlatformTag::new("manylinux_x86_64")?.to_string(),
456            "manylinux_x86_64"
457        );
458        Ok(())
459    }
460}