python_launcher/
lib.rs

1//! Search for Python interpreters in the environment
2//!
3//! This crate provides the code to both find Python interpreters installed and
4//! utilities to implement a CLI which mimic the [Python Launcher for Windows].
5//!
6//! # Layout
7//!
8//! At the top-level, the code directly related to searching is provided.
9//! The [`RequestedVersion`] enum represents the constraints the user has placed
10//! upon what version of Python they are searching for (ranging from any to a
11//! `major.minor` version). The [`ExactVersion`] struct represents an exact
12//! `major.minor` version of Python which was found.
13//!
14//! The [`cli`] module contains all code related to providing a CLI like the one
15//! the [Python Launcher for Windows] provides.
16//!
17//! [Python Launcher for Windows]: https://docs.python.org/3/using/windows.html#launcher
18
19pub mod cli;
20
21use std::{
22    collections::HashMap,
23    convert::From,
24    env, fmt,
25    fmt::Display,
26    num::ParseIntError,
27    path::{Path, PathBuf},
28    str::FromStr,
29};
30
31/// [`std::result::Result`] type with [`Error`] as the error type.
32pub type Result<T> = std::result::Result<T, Error>;
33
34/// Error enum for the entire crate.
35#[derive(Clone, Debug, PartialEq, Eq)]
36pub enum Error {
37    /// Parsing a digit component from a string fails.
38    ParseVersionComponentError(ParseIntError, String),
39    /// String parsing fails due to `.` missing.
40    DotMissing,
41    /// A [`Path`] lacks a file name when it is required.
42    FileNameMissing,
43    /// A file name cannot be converted to a string.
44    FileNameToStrError,
45    /// A file name is not structured appropriately.
46    PathFileNameError,
47    /// No Python executable could be found based on the constraints provided.
48    NoExecutableFound(RequestedVersion),
49    /// An illegal combination of CLI flags are provided.
50    IllegalArgument(PathBuf, String),
51}
52
53#[cfg(not(tarpaulin_include))]
54impl fmt::Display for Error {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        match self {
57            Error::ParseVersionComponentError(int_error, bad_value) => {
58                write!(f, "Error parsing '{bad_value}' as an integer: {int_error}")
59            }
60            Self::DotMissing => write!(f, "'.' missing from the version"),
61            Self::FileNameMissing => write!(f, "Path object lacks a file name"),
62            Self::FileNameToStrError => write!(f, "Failed to convert file name to `str`"),
63            Self::PathFileNameError => write!(f, "File name not of the format `pythonX.Y`"),
64            Self::NoExecutableFound(requested_version) => {
65                write!(f, "No executable found for {requested_version}")
66            }
67            Self::IllegalArgument(launcher_path, flag) => {
68                let printable_path = launcher_path.to_string_lossy();
69                write!(
70                    f,
71                    "The `{flag}` flag must be specified on its own; see `{printable_path} --help` for details"
72                )
73            }
74        }
75    }
76}
77
78#[cfg(not(tarpaulin_include))]
79impl std::error::Error for Error {
80    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
81        match self {
82            Self::ParseVersionComponentError(int_error, _) => Some(int_error),
83            Self::DotMissing => None,
84            Self::FileNameMissing => None,
85            Self::FileNameToStrError => None,
86            Self::PathFileNameError => None,
87            Self::NoExecutableFound(_) => None,
88            Self::IllegalArgument(_, _) => None,
89        }
90    }
91}
92
93#[cfg(not(tarpaulin_include))]
94impl Error {
95    /// Returns the appropriate [exit code](`exitcode::ExitCode`) for the error.
96    pub fn exit_code(&self) -> exitcode::ExitCode {
97        match self {
98            Self::ParseVersionComponentError(_, _) => exitcode::USAGE,
99            Self::DotMissing => exitcode::USAGE,
100            Self::FileNameMissing => exitcode::USAGE,
101            Self::FileNameToStrError => exitcode::SOFTWARE,
102            Self::PathFileNameError => exitcode::SOFTWARE,
103            Self::NoExecutableFound(_) => exitcode::USAGE,
104            Self::IllegalArgument(_, _) => exitcode::USAGE,
105        }
106    }
107}
108
109/// The integral part of a version specifier (e.g. the `3` or `10` of `3.10`).
110pub type ComponentSize = u16;
111
112/// The version of Python being searched for.
113///
114/// The constraints of what is being searched for can very from being
115/// open-ended/broad (i.e. [`RequestedVersion::Any`]) to as specific as
116/// `major.minor` (e.g. [`RequestedVersion::Exact`] to search for Python 3.10).
117#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
118pub enum RequestedVersion {
119    /// Any version of Python is acceptable.
120    Any,
121    /// A major version of Python is required (e.g. `3.x`).
122    MajorOnly(ComponentSize),
123    /// A specific `major.minor` version of Python is required (e.g. `3.9`).
124    Exact(ComponentSize, ComponentSize),
125}
126
127impl Display for RequestedVersion {
128    /// Format to a readable name of the Python version requested, e.g. `Python 3.9`.
129    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130        let repr = match self {
131            Self::Any => "Python".to_string(),
132            Self::MajorOnly(major) => format!("Python {major}"),
133            Self::Exact(major, minor) => format!("Python {major}.{minor}"),
134        };
135        write!(f, "{repr}")
136    }
137}
138
139impl FromStr for RequestedVersion {
140    type Err = Error;
141
142    fn from_str(version_string: &str) -> Result<Self> {
143        if version_string.is_empty() {
144            Ok(Self::Any)
145        } else if version_string.contains('.') {
146            let exact_version = ExactVersion::from_str(version_string)?;
147            Ok(Self::Exact(exact_version.major, exact_version.minor))
148        } else {
149            match version_string.parse::<ComponentSize>() {
150                Ok(number) => Ok(Self::MajorOnly(number)),
151                Err(parse_error) => Err(Error::ParseVersionComponentError(
152                    parse_error,
153                    version_string.to_string(),
154                )),
155            }
156        }
157    }
158}
159
160impl RequestedVersion {
161    /// Returns the [`String`] representing the environment variable for the
162    /// requested version (if applicable).
163    ///
164    /// # Examples
165    ///
166    /// Searching for [`RequestedVersion::Any`] provides an environment variable
167    /// which can be used to specify the default version of Python to use
168    /// (e.g. `3.10`).
169    ///
170    /// ```
171    /// let any_version = python_launcher::RequestedVersion::Any;
172    ///
173    /// assert_eq!(Some("PY_PYTHON".to_string()), any_version.env_var());
174    /// ```
175    ///
176    /// [`RequestedVersion::MajorOnly`] uses an environment variable which is
177    /// scoped to providing the default version for when the major version is
178    /// only specified.
179    ///
180    /// ```
181    /// let major_version = python_launcher::RequestedVersion::MajorOnly(3);
182    ///
183    /// assert_eq!(Some("PY_PYTHON3".to_string()), major_version.env_var());
184    /// ```
185    ///
186    /// When [`RequestedVersion::Exact`] is specified, there is no "default" to
187    /// provide/interpreter, and so no environment variable exists.
188    ///
189    /// ```
190    /// let exact_version = python_launcher::RequestedVersion::Exact(3, 10);
191    ///
192    /// assert!(exact_version.env_var().is_none());
193    /// ```
194    pub fn env_var(self) -> Option<String> {
195        match self {
196            Self::Any => Some("PY_PYTHON".to_string()),
197            Self::MajorOnly(major) => Some(format!("PY_PYTHON{major}")),
198            _ => None,
199        }
200    }
201}
202
203/// Specifies the `major.minor` version of a Python executable.
204///
205/// This struct is typically used to represent a found executable's version.
206#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
207pub struct ExactVersion {
208    /// The major version of Python, e.g. `3` of `3.10`.
209    pub major: ComponentSize,
210    /// The minor version of Python, e.g. `10` of `3.10`.
211    pub minor: ComponentSize,
212}
213
214impl From<ExactVersion> for RequestedVersion {
215    fn from(version: ExactVersion) -> Self {
216        Self::Exact(version.major, version.minor)
217    }
218}
219
220impl Display for ExactVersion {
221    /// Format to the format specifier, e.g. `3.9`.
222    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223        let major = self.major;
224        let minor = self.minor;
225        write!(f, "{major}.{minor}")
226    }
227}
228
229impl FromStr for ExactVersion {
230    type Err = Error;
231
232    fn from_str(version_string: &str) -> Result<Self> {
233        match version_string.find('.') {
234            Some(dot_index) => {
235                let major_str = &version_string[..dot_index];
236                let major = match major_str.parse::<ComponentSize>() {
237                    Ok(number) => number,
238                    Err(parse_error) => {
239                        return Err(Error::ParseVersionComponentError(
240                            parse_error,
241                            major_str.to_string(),
242                        ))
243                    }
244                };
245                let minor_str = &version_string[dot_index + 1..];
246
247                match minor_str.parse::<ComponentSize>() {
248                    Ok(minor) => Ok(Self { major, minor }),
249                    Err(parse_error) => Err(Error::ParseVersionComponentError(
250                        parse_error,
251                        minor_str.to_string(),
252                    )),
253                }
254            }
255            None => Err(Error::DotMissing),
256        }
257    }
258}
259
260fn acceptable_file_name(file_name: &str) -> bool {
261    file_name.len() >= "python3.0".len() && file_name.starts_with("python")
262}
263
264impl ExactVersion {
265    /// Construct an instance of [`ExactVersion`].
266    pub fn new(major: ComponentSize, minor: ComponentSize) -> Self {
267        ExactVersion { major, minor }
268    }
269
270    /// Constructs a [`ExactVersion`] from a `pythonX.Y` file path.
271    ///
272    /// # Errors
273    ///
274    /// If the [`Path`] is missing a file name component,
275    /// [`Error::FileNameMissing`] is returned.
276    ///
277    /// If the file name is not formatted appropriately,
278    /// [`Error::PathFileNameError`] is returned.
279    ///
280    /// When the [`Path`] cannot be converted to a [`&str`],
281    /// [`Error::FileNameToStrError`] is returned.
282    ///
283    /// # Examples
284    ///
285    /// ```
286    /// let expected = python_launcher::ExactVersion::new(3, 10);
287    /// let executable_path = std::path::Path::new("python3.10");
288    /// let exact_version = python_launcher::ExactVersion::from_path(executable_path);
289    ///
290    /// assert_eq!(Ok(expected), exact_version);
291    /// ```
292    pub fn from_path(path: &Path) -> Result<Self> {
293        path.file_name()
294            .ok_or(Error::FileNameMissing)
295            .and_then(|raw_file_name| match raw_file_name.to_str() {
296                Some(file_name) if acceptable_file_name(file_name) => {
297                    Self::from_str(&file_name["python".len()..])
298                }
299                Some(_) => Err(Error::PathFileNameError),
300                None => Err(Error::FileNameToStrError),
301            })
302    }
303
304    /// Tests whether this [`ExactVersion`] satisfies the [`RequestedVersion`].
305    ///
306    /// # Examples
307    ///
308    /// ```
309    /// let py3_10 = python_launcher::ExactVersion::new(3, 10);
310    /// let any_version = python_launcher::RequestedVersion::Any;
311    /// let py3_version = python_launcher::RequestedVersion::MajorOnly(3);
312    /// let py3_10_version = python_launcher::RequestedVersion::Exact(3, 10);
313    ///
314    /// assert!(py3_10.supports(any_version));
315    /// assert!(py3_10.supports(py3_version));
316    /// assert!(py3_10.supports(py3_10_version));
317    /// ```
318    pub fn supports(&self, requested: RequestedVersion) -> bool {
319        match requested {
320            RequestedVersion::Any => true,
321            RequestedVersion::MajorOnly(major_version) => self.major == major_version,
322            RequestedVersion::Exact(major_version, minor_version) => {
323                self.major == major_version && self.minor == minor_version
324            }
325        }
326    }
327}
328
329fn env_path() -> Vec<PathBuf> {
330    // Would love to have a return type of `impl Iterator<Item = PathBuf>
331    // and return just SplitPaths and iter::empty(), but Rust
332    // complains about differing return types.
333    match env::var_os("PATH") {
334        Some(path_val) => env::split_paths(&path_val).collect(),
335        None => Vec::new(),
336    }
337}
338
339fn flatten_directories(
340    directories: impl IntoIterator<Item = PathBuf>,
341) -> impl Iterator<Item = PathBuf> {
342    directories
343        .into_iter()
344        .filter_map(|p| p.read_dir().ok()) // Filter to Ok(ReadDir).
345        .flatten() // Flatten out `for DirEntry in ReadDir`.
346        .filter_map(|e| e.ok()) // Filter to Ok(DirEntry).
347        .map(|e| e.path()) // Get the PathBuf from the DirEntry.
348}
349
350fn all_executables_in_paths(
351    paths: impl IntoIterator<Item = PathBuf>,
352) -> HashMap<ExactVersion, PathBuf> {
353    let mut executables = HashMap::new();
354    paths.into_iter().for_each(|path| {
355        ExactVersion::from_path(&path).map_or((), |version| {
356            executables.entry(version).or_insert(path);
357        })
358    });
359
360    let found_executables = executables.values();
361    log::debug!("Found executables: {found_executables:?}",);
362    executables
363}
364
365/// Finds all possible Python executables on `PATH`.
366pub fn all_executables() -> HashMap<ExactVersion, PathBuf> {
367    log::info!("Checking PATH environment variable");
368    let path_entries = env_path();
369    log::debug!("PATH: {path_entries:?}");
370    let paths = flatten_directories(path_entries);
371    all_executables_in_paths(paths)
372}
373
374fn find_executable_in_hashmap(
375    requested: RequestedVersion,
376    found_executables: &HashMap<ExactVersion, PathBuf>,
377) -> Option<PathBuf> {
378    let mut iter = found_executables.iter();
379    match requested {
380        RequestedVersion::Any => iter.max(),
381        RequestedVersion::MajorOnly(_) => iter.filter(|pair| pair.0.supports(requested)).max(),
382        RequestedVersion::Exact(_, _) => iter.find(|pair| pair.0.supports(requested)),
383    }
384    .map(|pair| pair.1.clone())
385}
386
387/// Attempts to find an executable that satisfies a specified
388/// [`RequestedVersion`] on `PATH`.
389pub fn find_executable(requested: RequestedVersion) -> Option<PathBuf> {
390    let found_executables = all_executables();
391    find_executable_in_hashmap(requested, &found_executables)
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    use std::cmp::Ordering;
399
400    use test_case::test_case;
401
402    #[test_case(RequestedVersion::Any => "Python" ; "Any")]
403    #[test_case(RequestedVersion::MajorOnly(3) => "Python 3" ; "Major")]
404    #[test_case(RequestedVersion::Exact(3, 8) => "Python 3.8" ; "Exact/major.minor")]
405    fn requestedversion_to_string_tests(requested_version: RequestedVersion) -> String {
406        requested_version.to_string()
407    }
408
409    #[test_case(".3" => matches Err(Error::ParseVersionComponentError(_, _)) ; "missing major version is an error")]
410    #[test_case("3." => matches Err(Error::ParseVersionComponentError(_, _)) ; "missing minor version is an error")]
411    #[test_case("h" => matches Err(Error::ParseVersionComponentError(_, _)) ; "non-number, non-emptry string is an error")]
412    #[test_case("3.b" => matches Err(Error::ParseVersionComponentError(_, _)) ; "major.minor where minor is a non-number is an error")]
413    #[test_case("a.7" => matches Err(Error::ParseVersionComponentError(_, _)) ; "major.minor where major is a non-number is an error")]
414    #[test_case("" => Ok(RequestedVersion::Any) ; "empty string is Any")]
415    #[test_case("3" => Ok(RequestedVersion::MajorOnly(3)) ; "major-only version")]
416    #[test_case("3.8" => Ok(RequestedVersion::Exact(3, 8)) ; "major.minor")]
417    #[test_case("42.13" => Ok(RequestedVersion::Exact(42, 13)) ; "double digit version components")]
418    #[test_case("3.6.5" => matches Err(Error::ParseVersionComponentError(_, _)) ; "specifying a micro version is an error")]
419    fn requestedversion_from_str_tests(version_str: &str) -> Result<RequestedVersion> {
420        RequestedVersion::from_str(version_str)
421    }
422
423    #[test_case(RequestedVersion::Any => Some("PY_PYTHON".to_string()) ; "Any is PY_PYTHON")]
424    #[test_case(RequestedVersion::MajorOnly(3) => Some("PY_PYTHON3".to_string()) ; "major-only is PY_PYTHON{major}")]
425    #[test_case(RequestedVersion::MajorOnly(42) => Some("PY_PYTHON42".to_string()) ; "double-digit major component")]
426    #[test_case(RequestedVersion::Exact(42, 13) => None ; "exact/major.minor has no environment variable")]
427    fn requstedversion_env_var_tests(requested_version: RequestedVersion) -> Option<String> {
428        requested_version.env_var()
429    }
430
431    #[test]
432    fn test_requestedversion_from_exactversion() {
433        assert_eq!(
434            RequestedVersion::from(ExactVersion {
435                major: 42,
436                minor: 13
437            }),
438            RequestedVersion::Exact(42, 13)
439        );
440    }
441
442    #[test] // For some reason, having Ordering breaks test-case 1.0.0.
443    fn exactversion_comparisons() {
444        let py2_7 = ExactVersion { major: 2, minor: 7 };
445        let py3_0 = ExactVersion { major: 3, minor: 0 };
446        let py3_6 = ExactVersion { major: 3, minor: 6 };
447        let py3_10 = ExactVersion {
448            major: 3,
449            minor: 10,
450        };
451
452        // ==
453        assert_eq!(py3_10.cmp(&py3_10), Ordering::Equal);
454        // <
455        assert_eq!(py3_0.cmp(&py3_6), Ordering::Less);
456        // >
457        assert_eq!(py3_6.cmp(&py3_0), Ordering::Greater);
458        // Differ by major version.
459        assert_eq!(py2_7.cmp(&py3_0), Ordering::Less);
460        assert_eq!(py3_0.cmp(&py2_7), Ordering::Greater);
461        // Sort order different from lexicographic order.
462        assert_eq!(py3_6.cmp(&py3_10), Ordering::Less);
463        assert_eq!(py3_10.cmp(&py3_6), Ordering::Greater);
464    }
465
466    #[test_case(3, 8 => "3.8" ; "single digits")]
467    #[test_case(42, 13 => "42.13" ; "double digits")]
468    fn exactversion_to_string_tests(major: ComponentSize, minor: ComponentSize) -> String {
469        ExactVersion { major, minor }.to_string()
470    }
471
472    #[test_case("" => Err(Error::DotMissing) ; "empty string is an error")]
473    #[test_case("3" => Err(Error::DotMissing) ; "major-only version is an error")]
474    #[test_case(".7" => matches Err(Error::ParseVersionComponentError(_, _)) ; "missing major version is an error")]
475    #[test_case("3." => matches Err(Error::ParseVersionComponentError(_, _)) ; "missing minor version is an error")]
476    #[test_case("3.Y" => matches Err(Error::ParseVersionComponentError(_, _)) ; "non-digit minor version is an error")]
477    #[test_case("X.7" => matches Err(Error::ParseVersionComponentError(_, _)) ; "non-digit major version is an error")]
478    #[test_case("42.13" => Ok(ExactVersion {major: 42, minor: 13 }) ; "double digit version components")]
479    fn exactversion_from_str_tests(version_str: &str) -> Result<ExactVersion> {
480        ExactVersion::from_str(version_str)
481    }
482
483    #[test_case("/" => Err(Error::FileNameMissing) ; "path missing a file name is an error")]
484    #[test_case("/notpython" => Err(Error::PathFileNameError) ; "path not ending with 'python' is an error")]
485    #[test_case("/python3" => Err(Error::PathFileNameError) ; "filename lacking a minor component is an error")]
486    #[test_case("/pythonX.Y" => matches Err(Error::ParseVersionComponentError(_, _)) ; "filename with non-digit version is an error")]
487    #[test_case("/python42.13" => Ok(ExactVersion { major: 42, minor: 13 }) ; "double digit version components")]
488    fn exactversion_from_path_tests(path: &str) -> Result<ExactVersion> {
489        ExactVersion::from_path(&PathBuf::from(path))
490    }
491
492    #[test]
493    fn exactversion_from_path_invalid_utf8() {
494        // From https://doc.rust-lang.org/std/ffi/struct.OsStr.html#examples-2.
495        use std::ffi::OsStr;
496        use std::os::unix::ffi::OsStrExt;
497
498        let source = [0x66, 0x6f, 0x80, 0x6f];
499        let os_str = OsStr::from_bytes(&source[..]);
500        let path = PathBuf::from(os_str);
501        assert_eq!(
502            ExactVersion::from_path(&path),
503            Err(Error::FileNameToStrError)
504        );
505    }
506
507    #[allow(clippy::bool_assert_comparison)]
508    #[test_case(RequestedVersion::Any => true ; "Any supports all versions")]
509    #[test_case(RequestedVersion::MajorOnly(2) => false ; "major-only mismatch")]
510    #[test_case(RequestedVersion::MajorOnly(3) => true ; "major-only match")]
511    #[test_case(RequestedVersion::Exact(2, 7) => false ; "older major version")]
512    #[test_case(RequestedVersion::Exact(3, 5) => false ; "older minor version")]
513    #[test_case(RequestedVersion::Exact(4, 0) => false ; "newer major version")]
514    #[test_case(RequestedVersion::Exact(3, 7) => false ; "newer minor version")]
515    #[test_case(RequestedVersion::Exact(3, 6) => true ; "same version")]
516    fn exactversion_supports_tests(requested_version: RequestedVersion) -> bool {
517        let example = ExactVersion { major: 3, minor: 6 };
518        example.supports(requested_version)
519    }
520
521    #[test_case(2, 7, "/dir1/python2.7" ; "first directory")]
522    #[test_case(3, 6, "/dir1/python3.6" ; "matches in multiple directories")]
523    #[test_case(3, 7, "/dir2/python3.7" ; "last directory")]
524    fn all_executables_in_paths_tests(major: ComponentSize, minor: ComponentSize, path: &str) {
525        let python27_path = PathBuf::from("/dir1/python2.7");
526        let python36_dir1_path = PathBuf::from("/dir1/python3.6");
527        let python36_dir2_path = PathBuf::from("/dir2/python3.6");
528        let python37_path = PathBuf::from("/dir2/python3.7");
529        let files = vec![
530            python27_path,
531            python36_dir1_path,
532            python36_dir2_path,
533            python37_path,
534        ];
535
536        let executables = all_executables_in_paths(files);
537        assert_eq!(executables.len(), 3);
538
539        let version = ExactVersion { major, minor };
540        assert!(executables.contains_key(&version));
541        assert_eq!(executables.get(&version), Some(&PathBuf::from(path)));
542    }
543
544    #[test_case(RequestedVersion::Any => Some(PathBuf::from("/python3.7")) ; "Any version chooses newest version")]
545    #[test_case(RequestedVersion::MajorOnly(42) => None ; "major-only version newer than any options")]
546    #[test_case(RequestedVersion::MajorOnly(3) => Some(PathBuf::from("/python3.7")) ; "matching major version chooses newest minor version")]
547    #[test_case(RequestedVersion::Exact(3, 8) => None ; "version not available")]
548    #[test_case(RequestedVersion::Exact(3, 6) => Some(PathBuf::from("/python3.6")) ; "exact version match")]
549    fn find_executable_in_hashmap_tests(requested_version: RequestedVersion) -> Option<PathBuf> {
550        let mut executables = HashMap::new();
551        assert_eq!(
552            find_executable_in_hashmap(RequestedVersion::Any, &executables),
553            None
554        );
555
556        let python36_path = PathBuf::from("/python3.6");
557        executables.insert(ExactVersion { major: 3, minor: 6 }, python36_path);
558
559        let python37_path = PathBuf::from("/python3.7");
560        executables.insert(ExactVersion { major: 3, minor: 7 }, python37_path);
561
562        find_executable_in_hashmap(requested_version, &executables)
563    }
564}