Skip to main content

ty_module_resolver/
typeshed.rs

1use std::collections::BTreeMap;
2use std::fmt;
3use std::num::{NonZeroU16, NonZeroUsize};
4use std::ops::{RangeFrom, RangeInclusive};
5use std::str::FromStr;
6
7use ruff_db::vendored::VendoredFileSystem;
8use ruff_python_ast::{PythonVersion, PythonVersionDeserializationError};
9
10use crate::FxOrderMap;
11use crate::module_name::ModuleName;
12
13pub(crate) fn vendored_typeshed_versions(vendored: &VendoredFileSystem) -> TypeshedVersions {
14    TypeshedVersions::from_str(
15        &vendored
16            .read_to_string("stdlib/VERSIONS")
17            .expect("The vendored typeshed stubs should contain a VERSIONS file"),
18    )
19    .expect("The VERSIONS file in the vendored typeshed stubs should be well-formed")
20}
21
22#[derive(Debug, PartialEq, Eq, Clone)]
23pub struct TypeshedVersionsParseError {
24    line_number: Option<NonZeroU16>,
25    reason: TypeshedVersionsParseErrorKind,
26}
27
28impl fmt::Display for TypeshedVersionsParseError {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        let TypeshedVersionsParseError {
31            line_number,
32            reason,
33        } = self;
34        if let Some(line_number) = line_number {
35            write!(
36                f,
37                "Error while parsing line {line_number} of typeshed's VERSIONS file: {reason}"
38            )
39        } else {
40            write!(f, "Error while parsing typeshed's VERSIONS file: {reason}")
41        }
42    }
43}
44
45impl std::error::Error for TypeshedVersionsParseError {
46    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
47        if let TypeshedVersionsParseErrorKind::VersionParseError(err) = &self.reason {
48            err.source()
49        } else {
50            None
51        }
52    }
53}
54
55#[derive(Debug, PartialEq, Eq, Clone, thiserror::Error)]
56pub enum TypeshedVersionsParseErrorKind {
57    #[error("File has too many lines ({0}); maximum allowed is {max_allowed}", max_allowed = NonZeroU16::MAX)]
58    TooManyLines(NonZeroUsize),
59    #[error("Expected every non-comment line to have exactly one colon")]
60    UnexpectedNumberOfColons,
61    #[error("Expected all components of '{0}' to be valid Python identifiers")]
62    InvalidModuleName(String),
63    #[error("Expected every non-comment line to have exactly one '-' character")]
64    UnexpectedNumberOfHyphens,
65    #[error("{0}")]
66    VersionParseError(#[from] PythonVersionDeserializationError),
67}
68
69#[derive(Clone, Debug, PartialEq, Eq, Hash, get_size2::GetSize)]
70pub struct TypeshedVersions(FxOrderMap<ModuleName, PyVersionRange>);
71
72impl TypeshedVersions {
73    #[must_use]
74    pub fn exact(&self, module_name: &ModuleName) -> Option<&PyVersionRange> {
75        self.0.get(module_name)
76    }
77
78    #[must_use]
79    pub(crate) fn query_module(
80        &self,
81        module: &ModuleName,
82        python_version: PythonVersion,
83    ) -> TypeshedVersionsQueryResult {
84        if let Some(range) = self.exact(module) {
85            if range.contains(python_version) {
86                TypeshedVersionsQueryResult::Exists
87            } else {
88                TypeshedVersionsQueryResult::DoesNotExist
89            }
90        } else {
91            let mut module = module.parent();
92            while let Some(module_to_try) = module {
93                if let Some(range) = self.exact(&module_to_try) {
94                    return {
95                        if range.contains(python_version) {
96                            TypeshedVersionsQueryResult::MaybeExists
97                        } else {
98                            TypeshedVersionsQueryResult::DoesNotExist
99                        }
100                    };
101                }
102                module = module_to_try.parent();
103            }
104            TypeshedVersionsQueryResult::DoesNotExist
105        }
106    }
107}
108
109/// Possible answers [`TypeshedVersions::query_module()`] could give to the question:
110/// "Does this module exist in the stdlib at runtime on a certain target version?"
111#[derive(Debug, Copy, PartialEq, Eq, Clone, Hash)]
112pub(crate) enum TypeshedVersionsQueryResult {
113    /// The module definitely exists in the stdlib at runtime on the user-specified target version.
114    ///
115    /// For example:
116    /// - The target version is Python 3.8
117    /// - We're querying whether the `asyncio.tasks` module exists in the stdlib
118    /// - The VERSIONS file contains the line `asyncio.tasks: 3.8-`
119    Exists,
120
121    /// The module definitely does not exist in the stdlib on the user-specified target version.
122    ///
123    /// For example:
124    /// - We're querying whether the `foo` module exists in the stdlib
125    /// - There is no top-level `foo` module in VERSIONS
126    ///
127    /// OR:
128    /// - The target version is Python 3.8
129    /// - We're querying whether the module `importlib.abc` exists in the stdlib
130    /// - The VERSIONS file contains the line `importlib.abc: 3.10-`,
131    ///   indicating that the module was added in 3.10
132    ///
133    /// OR:
134    /// - The target version is Python 3.8
135    /// - We're querying whether the module `collections.abc` exists in the stdlib
136    /// - The VERSIONS file does not contain any information about the `collections.abc` submodule,
137    ///   but *does* contain the line `collections: 3.10-`,
138    ///   indicating that the entire `collections` package was added in Python 3.10.
139    DoesNotExist,
140
141    /// The module potentially exists in the stdlib and, if it does,
142    /// it definitely exists on the user-specified target version.
143    ///
144    /// This variant is only relevant for submodules,
145    /// for which the typeshed VERSIONS file does not provide comprehensive information.
146    /// (The VERSIONS file is guaranteed to provide information about all top-level stdlib modules and packages,
147    /// but not necessarily about all submodules within each top-level package.)
148    ///
149    /// For example:
150    /// - The target version is Python 3.8
151    /// - We're querying whether the `asyncio.staggered` module exists in the stdlib
152    /// - The typeshed VERSIONS file contains the line `asyncio: 3.8`,
153    ///   indicating that the `asyncio` package was added in Python 3.8,
154    ///   but does not contain any explicit information about the `asyncio.staggered` submodule.
155    MaybeExists,
156}
157
158impl FromStr for TypeshedVersions {
159    type Err = TypeshedVersionsParseError;
160
161    fn from_str(s: &str) -> Result<Self, Self::Err> {
162        let mut map = FxOrderMap::default();
163
164        for (line_index, line) in s.lines().enumerate() {
165            // humans expect line numbers to be 1-indexed
166            let line_number = NonZeroUsize::new(line_index.saturating_add(1)).unwrap();
167
168            let Ok(line_number) = NonZeroU16::try_from(line_number) else {
169                return Err(TypeshedVersionsParseError {
170                    line_number: None,
171                    reason: TypeshedVersionsParseErrorKind::TooManyLines(line_number),
172                });
173            };
174
175            let Some(content) = line.split('#').map(str::trim).next() else {
176                continue;
177            };
178            if content.is_empty() {
179                continue;
180            }
181
182            let mut parts = content.split(':').map(str::trim);
183            let (Some(module_name), Some(rest), None) = (parts.next(), parts.next(), parts.next())
184            else {
185                return Err(TypeshedVersionsParseError {
186                    line_number: Some(line_number),
187                    reason: TypeshedVersionsParseErrorKind::UnexpectedNumberOfColons,
188                });
189            };
190
191            let Some(module_name) = ModuleName::new(module_name) else {
192                return Err(TypeshedVersionsParseError {
193                    line_number: Some(line_number),
194                    reason: TypeshedVersionsParseErrorKind::InvalidModuleName(
195                        module_name.to_string(),
196                    ),
197                });
198            };
199
200            match PyVersionRange::from_str(rest) {
201                Ok(version) => map.insert(module_name, version),
202                Err(reason) => {
203                    return Err(TypeshedVersionsParseError {
204                        line_number: Some(line_number),
205                        reason,
206                    });
207                }
208            };
209        }
210
211        Ok(Self(map))
212    }
213}
214
215impl fmt::Display for TypeshedVersions {
216    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217        let sorted_items: BTreeMap<&ModuleName, &PyVersionRange> = self.0.iter().collect();
218        for (module_name, range) in sorted_items {
219            writeln!(f, "{module_name}: {range}")?;
220        }
221        Ok(())
222    }
223}
224
225#[derive(Debug, Clone, Eq, PartialEq, Hash, get_size2::GetSize)]
226pub enum PyVersionRange {
227    AvailableFrom(RangeFrom<PythonVersion>),
228    AvailableWithin(RangeInclusive<PythonVersion>),
229}
230
231impl PyVersionRange {
232    #[must_use]
233    pub fn contains(&self, version: PythonVersion) -> bool {
234        match self {
235            Self::AvailableFrom(inner) => inner.contains(&version),
236            Self::AvailableWithin(inner) => inner.contains(&version),
237        }
238    }
239
240    /// Display the version range in a way that is suitable for rendering in user-facing diagnostics.
241    pub fn diagnostic_display(&self) -> impl std::fmt::Display {
242        fmt::from_fn(|f| match self {
243            PyVersionRange::AvailableFrom(range_from) => write!(f, "{}+", range_from.start),
244            PyVersionRange::AvailableWithin(range_inclusive) => {
245                // Don't trust the start Python version if it's 3.0 or lower.
246                // Typeshed doesn't attempt to give accurate start versions if a module was added
247                // in the Python 2 era.
248                if range_inclusive.start() <= &(PythonVersion { major: 3, minor: 0 }) {
249                    write!(f, "<={}", range_inclusive.end())
250                } else {
251                    write!(f, "{}-{}", range_inclusive.start(), range_inclusive.end())
252                }
253            }
254        })
255    }
256}
257
258impl FromStr for PyVersionRange {
259    type Err = TypeshedVersionsParseErrorKind;
260
261    fn from_str(s: &str) -> Result<Self, Self::Err> {
262        let mut parts = s.split('-').map(str::trim);
263        match (parts.next(), parts.next(), parts.next()) {
264            (Some(lower), Some(""), None) => {
265                let lower = PythonVersion::from_str(lower)?;
266                Ok(Self::AvailableFrom(lower..))
267            }
268            (Some(lower), Some(upper), None) => {
269                let lower = PythonVersion::from_str(lower)?;
270                let upper = PythonVersion::from_str(upper)?;
271                Ok(Self::AvailableWithin(lower..=upper))
272            }
273            _ => Err(TypeshedVersionsParseErrorKind::UnexpectedNumberOfHyphens),
274        }
275    }
276}
277
278impl fmt::Display for PyVersionRange {
279    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
280        match self {
281            Self::AvailableFrom(range_from) => write!(f, "{}-", range_from.start),
282            Self::AvailableWithin(range_inclusive) => {
283                write!(f, "{}-{}", range_inclusive.start(), range_inclusive.end())
284            }
285        }
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    #![expect(
292        clippy::disallowed_methods,
293        reason = "These are tests, so it's fine to do I/O by-passing System."
294    )]
295
296    use std::fmt::Write as _;
297    use std::num::{IntErrorKind, NonZeroU16};
298    use std::path::Path;
299
300    use super::*;
301    use insta::assert_snapshot;
302
303    const TYPESHED_STDLIB_DIR: &str = "stdlib";
304
305    const ONE: Option<NonZeroU16> = Some(NonZeroU16::new(1).unwrap());
306
307    impl TypeshedVersions {
308        #[must_use]
309        fn contains_exact(&self, module: &ModuleName) -> bool {
310            self.exact(module).is_some()
311        }
312
313        #[must_use]
314        fn len(&self) -> usize {
315            self.0.len()
316        }
317    }
318
319    #[test]
320    fn can_parse_vendored_versions_file() {
321        let versions = vendored_typeshed_versions(ty_vendored::file_system());
322        assert!(versions.len() > 100);
323        assert!(versions.len() < 1000);
324
325        let asyncio = ModuleName::new_static("asyncio").unwrap();
326        let asyncio_staggered = ModuleName::new_static("asyncio.staggered").unwrap();
327        let audioop = ModuleName::new_static("audioop").unwrap();
328
329        assert!(versions.contains_exact(&asyncio));
330        assert_eq!(
331            versions.query_module(&asyncio, PythonVersion::PY310),
332            TypeshedVersionsQueryResult::Exists
333        );
334
335        assert!(versions.contains_exact(&asyncio_staggered));
336        assert_eq!(
337            versions.query_module(&asyncio_staggered, PythonVersion::PY38),
338            TypeshedVersionsQueryResult::Exists
339        );
340        assert_eq!(
341            versions.query_module(&asyncio_staggered, PythonVersion::PY37),
342            TypeshedVersionsQueryResult::DoesNotExist
343        );
344
345        assert!(versions.contains_exact(&audioop));
346        assert_eq!(
347            versions.query_module(&audioop, PythonVersion::PY312),
348            TypeshedVersionsQueryResult::Exists
349        );
350        assert_eq!(
351            versions.query_module(&audioop, PythonVersion::PY313),
352            TypeshedVersionsQueryResult::DoesNotExist
353        );
354    }
355
356    #[test]
357    fn typeshed_versions_consistent_with_vendored_stubs() {
358        let vendored_typeshed_versions = vendored_typeshed_versions(ty_vendored::file_system());
359        let vendored_typeshed_dir =
360            Path::new(env!("CARGO_MANIFEST_DIR")).join("../ty_vendored/vendor/typeshed");
361
362        let mut empty_iterator = true;
363
364        let stdlib_stubs_path = vendored_typeshed_dir.join(TYPESHED_STDLIB_DIR);
365
366        for entry in std::fs::read_dir(&stdlib_stubs_path).unwrap() {
367            empty_iterator = false;
368            let entry = entry.unwrap();
369            let absolute_path = entry.path();
370
371            let relative_path = absolute_path
372                .strip_prefix(&stdlib_stubs_path)
373                .unwrap_or_else(|_| {
374                    panic!(
375                        "Expected path to be a child of {stdlib_stubs_path:?} \
376                        but found {absolute_path:?}"
377                    )
378                });
379
380            let relative_path_str = relative_path.as_os_str().to_str().unwrap_or_else(|| {
381                panic!("Expected all typeshed paths to be valid UTF-8; got {relative_path:?}")
382            });
383            if relative_path_str == "VERSIONS" {
384                continue;
385            }
386
387            let top_level_module = if let Some(extension) = relative_path.extension() {
388                // It was a file; strip off the file extension to get the module name:
389                let extension = extension.to_str().unwrap_or_else(|| {
390                    panic!(
391                        "Expected all file extensions to be UTF-8; \
392                        was not true for {relative_path:?}"
393                    )
394                });
395
396                relative_path_str
397                    .strip_suffix(extension)
398                    .and_then(|string| string.strip_suffix('.'))
399                    .unwrap_or_else(|| {
400                        panic!(
401                            "Expected path {relative_path_str:?} to end \
402                            with computed extension {extension:?}"
403                        )
404                    })
405            } else {
406                // It was a directory; no need to do anything to get the module name
407                relative_path_str
408            };
409
410            let top_level_module = ModuleName::new(top_level_module)
411                .unwrap_or_else(|| panic!("{top_level_module:?} was not a valid module name!"));
412
413            assert!(vendored_typeshed_versions.contains_exact(&top_level_module));
414        }
415
416        assert!(
417            !empty_iterator,
418            "Expected there to be at least one file or directory in the vendored typeshed stubs"
419        );
420    }
421
422    #[test]
423    fn can_parse_mock_versions_file() {
424        const VERSIONS: &str = "\
425# a comment
426    # some more comment
427# yet more comment
428
429
430# and some more comment
431
432bar: 2.7-3.10
433
434# more comment
435bar.baz: 3.1-3.9
436foo: 3.8-   # trailing comment
437";
438        let parsed_versions = TypeshedVersions::from_str(VERSIONS).unwrap();
439        assert_eq!(parsed_versions.len(), 3);
440        assert_snapshot!(parsed_versions.to_string(), @"
441        bar: 2.7-3.10
442        bar.baz: 3.1-3.9
443        foo: 3.8-
444        "
445        );
446    }
447
448    #[test]
449    fn version_within_range_parsed_correctly() {
450        let parsed_versions = TypeshedVersions::from_str("bar: 2.7-3.10").unwrap();
451        let bar = ModuleName::new_static("bar").unwrap();
452
453        assert!(parsed_versions.contains_exact(&bar));
454        assert_eq!(
455            parsed_versions.query_module(&bar, PythonVersion::PY37),
456            TypeshedVersionsQueryResult::Exists
457        );
458        assert_eq!(
459            parsed_versions.query_module(&bar, PythonVersion::PY310),
460            TypeshedVersionsQueryResult::Exists
461        );
462        assert_eq!(
463            parsed_versions.query_module(&bar, PythonVersion::PY311),
464            TypeshedVersionsQueryResult::DoesNotExist
465        );
466    }
467
468    #[test]
469    fn version_from_range_parsed_correctly() {
470        let parsed_versions = TypeshedVersions::from_str("foo: 3.8-").unwrap();
471        let foo = ModuleName::new_static("foo").unwrap();
472
473        assert!(parsed_versions.contains_exact(&foo));
474        assert_eq!(
475            parsed_versions.query_module(&foo, PythonVersion::PY37),
476            TypeshedVersionsQueryResult::DoesNotExist
477        );
478        assert_eq!(
479            parsed_versions.query_module(&foo, PythonVersion::PY38),
480            TypeshedVersionsQueryResult::Exists
481        );
482        assert_eq!(
483            parsed_versions.query_module(&foo, PythonVersion::PY311),
484            TypeshedVersionsQueryResult::Exists
485        );
486    }
487
488    #[test]
489    fn explicit_submodule_parsed_correctly() {
490        let parsed_versions = TypeshedVersions::from_str("bar.baz: 3.1-3.9").unwrap();
491        let bar_baz = ModuleName::new_static("bar.baz").unwrap();
492
493        assert!(parsed_versions.contains_exact(&bar_baz));
494        assert_eq!(
495            parsed_versions.query_module(&bar_baz, PythonVersion::PY37),
496            TypeshedVersionsQueryResult::Exists
497        );
498        assert_eq!(
499            parsed_versions.query_module(&bar_baz, PythonVersion::PY39),
500            TypeshedVersionsQueryResult::Exists
501        );
502        assert_eq!(
503            parsed_versions.query_module(&bar_baz, PythonVersion::PY310),
504            TypeshedVersionsQueryResult::DoesNotExist
505        );
506    }
507
508    #[test]
509    fn implicit_submodule_queried_correctly() {
510        let parsed_versions = TypeshedVersions::from_str("bar: 2.7-3.10").unwrap();
511        let bar_eggs = ModuleName::new_static("bar.eggs").unwrap();
512
513        assert!(!parsed_versions.contains_exact(&bar_eggs));
514        assert_eq!(
515            parsed_versions.query_module(&bar_eggs, PythonVersion::PY37),
516            TypeshedVersionsQueryResult::MaybeExists
517        );
518        assert_eq!(
519            parsed_versions.query_module(&bar_eggs, PythonVersion::PY310),
520            TypeshedVersionsQueryResult::MaybeExists
521        );
522        assert_eq!(
523            parsed_versions.query_module(&bar_eggs, PythonVersion::PY311),
524            TypeshedVersionsQueryResult::DoesNotExist
525        );
526    }
527
528    #[test]
529    fn nonexistent_module_queried_correctly() {
530        let parsed_versions = TypeshedVersions::from_str("eggs: 3.8-").unwrap();
531        let spam = ModuleName::new_static("spam").unwrap();
532
533        assert!(!parsed_versions.contains_exact(&spam));
534        assert_eq!(
535            parsed_versions.query_module(&spam, PythonVersion::PY37),
536            TypeshedVersionsQueryResult::DoesNotExist
537        );
538        assert_eq!(
539            parsed_versions.query_module(&spam, PythonVersion::PY313),
540            TypeshedVersionsQueryResult::DoesNotExist
541        );
542    }
543
544    #[test]
545    fn invalid_huge_versions_file() {
546        let offset = 100;
547        let too_many = u16::MAX as usize + offset;
548
549        let mut massive_versions_file = String::new();
550        for i in 0..too_many {
551            let _ = writeln!(&mut massive_versions_file, "x{i}: 3.8-");
552        }
553
554        assert_eq!(
555            TypeshedVersions::from_str(&massive_versions_file),
556            Err(TypeshedVersionsParseError {
557                line_number: None,
558                reason: TypeshedVersionsParseErrorKind::TooManyLines(
559                    NonZeroUsize::new(too_many + 1 - offset).unwrap()
560                )
561            })
562        );
563    }
564
565    #[test]
566    fn invalid_typeshed_versions_bad_colon_number() {
567        assert_eq!(
568            TypeshedVersions::from_str("foo 3.7"),
569            Err(TypeshedVersionsParseError {
570                line_number: ONE,
571                reason: TypeshedVersionsParseErrorKind::UnexpectedNumberOfColons
572            })
573        );
574        assert_eq!(
575            TypeshedVersions::from_str("foo:: 3.7"),
576            Err(TypeshedVersionsParseError {
577                line_number: ONE,
578                reason: TypeshedVersionsParseErrorKind::UnexpectedNumberOfColons
579            })
580        );
581    }
582
583    #[test]
584    fn invalid_typeshed_versions_non_identifier_modules() {
585        assert_eq!(
586            TypeshedVersions::from_str("not!an!identifier!: 3.7"),
587            Err(TypeshedVersionsParseError {
588                line_number: ONE,
589                reason: TypeshedVersionsParseErrorKind::InvalidModuleName(
590                    "not!an!identifier!".to_string()
591                )
592            })
593        );
594        assert_eq!(
595            TypeshedVersions::from_str("(also_not).(an_identifier): 3.7"),
596            Err(TypeshedVersionsParseError {
597                line_number: ONE,
598                reason: TypeshedVersionsParseErrorKind::InvalidModuleName(
599                    "(also_not).(an_identifier)".to_string()
600                )
601            })
602        );
603    }
604
605    #[test]
606    fn invalid_typeshed_versions_bad_hyphen_number() {
607        assert_eq!(
608            TypeshedVersions::from_str("foo: 3.8"),
609            Err(TypeshedVersionsParseError {
610                line_number: ONE,
611                reason: TypeshedVersionsParseErrorKind::UnexpectedNumberOfHyphens
612            })
613        );
614        assert_eq!(
615            TypeshedVersions::from_str("foo: 3.8--"),
616            Err(TypeshedVersionsParseError {
617                line_number: ONE,
618                reason: TypeshedVersionsParseErrorKind::UnexpectedNumberOfHyphens
619            })
620        );
621        assert_eq!(
622            TypeshedVersions::from_str("foo: 3.8--3.9"),
623            Err(TypeshedVersionsParseError {
624                line_number: ONE,
625                reason: TypeshedVersionsParseErrorKind::UnexpectedNumberOfHyphens
626            })
627        );
628    }
629
630    #[test]
631    fn invalid_typeshed_versions_bad_period_number() {
632        assert_eq!(
633            TypeshedVersions::from_str("foo: 38-"),
634            Err(TypeshedVersionsParseError {
635                line_number: ONE,
636                reason: TypeshedVersionsParseErrorKind::VersionParseError(
637                    PythonVersionDeserializationError::WrongPeriodNumber(Box::from("38"))
638                )
639            })
640        );
641        assert_eq!(
642            TypeshedVersions::from_str("foo: 3..8-"),
643            Err(TypeshedVersionsParseError {
644                line_number: ONE,
645                reason: TypeshedVersionsParseErrorKind::VersionParseError(
646                    PythonVersionDeserializationError::WrongPeriodNumber(Box::from("3..8"))
647                )
648            })
649        );
650        assert_eq!(
651            TypeshedVersions::from_str("foo: 3.8-3..11"),
652            Err(TypeshedVersionsParseError {
653                line_number: ONE,
654                reason: TypeshedVersionsParseErrorKind::VersionParseError(
655                    PythonVersionDeserializationError::WrongPeriodNumber(Box::from("3..11"))
656                )
657            })
658        );
659    }
660
661    #[test]
662    fn invalid_typeshed_versions_non_digits() {
663        let err = TypeshedVersions::from_str("foo: 1.two-").unwrap_err();
664        assert_eq!(err.line_number, ONE);
665        let TypeshedVersionsParseErrorKind::VersionParseError(
666            PythonVersionDeserializationError::InvalidMinorVersion(invalid_minor, parse_error),
667        ) = err.reason
668        else {
669            panic!(
670                "Expected an invalid-minor-version parse error, got `{}`",
671                err.reason
672            )
673        };
674        assert_eq!(&*invalid_minor, "two");
675        assert_eq!(*parse_error.kind(), IntErrorKind::InvalidDigit);
676
677        let err = TypeshedVersions::from_str("foo: 3.8-four.9").unwrap_err();
678        assert_eq!(err.line_number, ONE);
679        let TypeshedVersionsParseErrorKind::VersionParseError(
680            PythonVersionDeserializationError::InvalidMajorVersion(invalid_major, parse_error),
681        ) = err.reason
682        else {
683            panic!(
684                "Expected an invalid-major-version parse error, got `{}`",
685                err.reason
686            )
687        };
688        assert_eq!(&*invalid_major, "four");
689        assert_eq!(*parse_error.kind(), IntErrorKind::InvalidDigit);
690    }
691}