1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
9pub struct PythonMajorVersion(u16);
10
11impl PythonMajorVersion {
12 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 #[must_use]
27 pub const fn get(self) -> u16 {
28 self.0
29 }
30}
31
32#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
34pub struct PythonMinorVersion(u16);
35
36impl PythonMinorVersion {
37 #[must_use]
39 pub const fn new(value: u16) -> Self {
40 Self(value)
41 }
42
43 #[must_use]
45 pub const fn get(self) -> u16 {
46 self.0
47 }
48}
49
50#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
52pub struct PythonPatchVersion(u16);
53
54impl PythonPatchVersion {
55 #[must_use]
57 pub const fn new(value: u16) -> Self {
58 Self(value)
59 }
60
61 #[must_use]
63 pub const fn get(self) -> u16 {
64 self.0
65 }
66}
67
68#[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 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 #[must_use]
103 pub const fn major(&self) -> u16 {
104 self.major.get()
105 }
106
107 #[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 #[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 #[must_use]
127 pub const fn is_python3(&self) -> bool {
128 self.major() == 3
129 }
130
131 #[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 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
174pub enum PythonVersionFamily {
175 Python2,
176 Python3,
177}
178
179impl PythonVersionFamily {
180 #[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#[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 #[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 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 #[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#[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}