python_launcher/
cli.rs

1//! Parsing of CLI flags
2//!
3//! The [`Action`] enum represents what action to perform based on the
4//! command-line arguments passed to the program.
5
6use std::{
7    collections::HashMap,
8    env,
9    fmt::Write,
10    fs::File,
11    io::{BufRead, BufReader, Read},
12    path::{Path, PathBuf},
13    str::FromStr,
14    string::ToString,
15};
16
17use comfy_table::{Table, TableComponent};
18
19use crate::{ExactVersion, RequestedVersion};
20
21/// The expected directory name for virtual environments.
22pub static DEFAULT_VENV_DIR: &str = ".venv";
23
24/// Represents the possible outcomes based on CLI arguments.
25#[derive(Clone, Debug, Hash, PartialEq, Eq)]
26pub enum Action {
27    /// The help string for the Python Launcher along with the path to a Python
28    /// executable.
29    ///
30    /// The executable path is so that it can be executed with `-h` to append
31    /// Python's own help output.
32    Help(String, PathBuf),
33    /// A string listing all found executables on `PATH`.
34    ///
35    /// The string is formatted to be human-readable.
36    List(String),
37    /// Details for executing a Python executable.
38    Execute {
39        /// The Python Launcher used to find the Python executable.
40        launcher_path: PathBuf,
41        /// The Python executable to run.
42        executable: PathBuf,
43        /// Arguments to the executable.
44        args: Vec<String>,
45    },
46}
47
48impl Action {
49    /// Parses CLI arguments to determine what action should be taken.
50    ///
51    /// The first argument -- `argv[0]` -- is considered the path to the
52    /// Launcher itself (i.e. [`Action::Execute::launcher_path`]).
53    ///
54    /// The second argument -- `argv.get(1)` -- is used to determine if/what
55    /// argument has been provided for the Launcher.
56    ///
57    /// # Launcher Arguments
58    ///
59    /// ## `-h`/`--help`
60    ///
61    /// Returns [`Action::Help`].
62    ///
63    /// The search for the Python executable to use is done using
64    /// [`crate::find_executable`] with an [`RequestedVersion::Any`] argument.
65    ///
66    /// ## `--list`
67    ///
68    /// Returns [`Action::List`].
69    ///
70    /// The list of executable is gathered via [`crate::all_executables`].
71    ///
72    /// ## Version Restriction
73    ///
74    /// Returns the appropriate [`Action::Execute`] instance for the requested
75    /// Python version.
76    ///
77    /// [`crate::find_executable`] is used to perform the search.
78    ///
79    /// ## No Arguments for the Launcher
80    ///
81    /// Returns an [`Action::Execute`] instance.
82    ///
83    /// As a first step, a check is done for an activated virtual environment
84    /// via the `VIRTUAL_ENV` environment variable. If none is set, look for a
85    /// virtual environment in a directory named by [`DEFAULT_VENV_DIR`] in the
86    /// current or any parent directories.
87    ///
88    /// If no virtual environment is found, a shebang line is searched for in
89    /// the first argument to the Python interpreter. If one is found then it
90    /// is used to (potentially) restrict the requested version searched for.
91    ///
92    /// The search for an interpreter proceeds using [`crate::find_executable`].
93    ///
94    /// # Errors
95    ///
96    /// If `-h`, `--help`, or `--list` are specified as the first argument but
97    /// there are other arguments, [`crate::Error::IllegalArgument`] is returned.
98    ///
99    /// If no executable could be found for [`Action::Help`] or
100    /// [`Action::List`], [`crate::Error::NoExecutableFound`] is returned.
101    ///
102    /// # Panics
103    ///
104    /// - If a [`writeln!`] call fails.
105    /// - If the current directory cannot be accessed.
106    pub fn from_main(argv: &[String]) -> crate::Result<Self> {
107        let launcher_path = PathBuf::from(&argv[0]); // Strip the path to this executable.
108
109        match argv.get(1) {
110            Some(flag) if flag == "-h" || flag == "--help" || flag == "--list" => {
111                if argv.len() > 2 {
112                    Err(crate::Error::IllegalArgument(
113                        launcher_path,
114                        flag.to_string(),
115                    ))
116                } else if flag == "--list" {
117                    Ok(Action::List(list_executables(&crate::all_executables())?))
118                } else {
119                    crate::find_executable(RequestedVersion::Any)
120                        .ok_or(crate::Error::NoExecutableFound(RequestedVersion::Any))
121                        .map(|executable_path| {
122                            Action::Help(
123                                help_message(&launcher_path, &executable_path),
124                                executable_path,
125                            )
126                        })
127                }
128            }
129            Some(version) if version_from_flag(version).is_some() => {
130                Ok(Action::Execute {
131                    launcher_path,
132                    // Make sure to skip the app path and version specification.
133                    executable: find_executable(version_from_flag(version).unwrap(), &argv[2..])?,
134                    args: argv[2..].to_vec(),
135                })
136            }
137            Some(_) | None => Ok(Action::Execute {
138                launcher_path,
139                // Make sure to skip the app path.
140                executable: find_executable(RequestedVersion::Any, &argv[1..])?,
141                args: argv[1..].to_vec(),
142            }),
143        }
144    }
145}
146
147fn help_message(launcher_path: &Path, executable_path: &Path) -> String {
148    let mut message = String::new();
149    writeln!(
150        message,
151        include_str!("HELP.txt"),
152        env!("CARGO_PKG_VERSION"),
153        launcher_path.to_string_lossy(),
154        executable_path.to_string_lossy()
155    )
156    .unwrap();
157    message
158}
159
160/// Attempts to find a version specifier from a CLI argument.
161///
162/// It is assumed that the flag from the command-line is passed as-is
163/// (i.e. the flag starts with `-`).
164fn version_from_flag(arg: &str) -> Option<RequestedVersion> {
165    if !arg.starts_with('-') {
166        None
167    } else {
168        RequestedVersion::from_str(&arg[1..]).ok()
169    }
170}
171
172fn list_executables(executables: &HashMap<ExactVersion, PathBuf>) -> crate::Result<String> {
173    if executables.is_empty() {
174        return Err(crate::Error::NoExecutableFound(RequestedVersion::Any));
175    }
176
177    let mut executable_pairs = Vec::from_iter(executables);
178    executable_pairs.sort_unstable();
179    executable_pairs.reverse();
180
181    let mut table = Table::new();
182    table.load_preset(comfy_table::presets::NOTHING);
183    // Using U+2502/"Box Drawings Light Vertical" over
184    // U+007C/"Vertical Line"/pipe simply because it looks better.
185    // Leaving out a header and other decorations to make it easier
186    // parse the output.
187    table.set_style(TableComponent::VerticalLines, '│');
188
189    for (version, path) in executable_pairs {
190        table.add_row(vec![version.to_string(), path.display().to_string()]);
191    }
192
193    Ok(table.to_string() + "\n")
194}
195
196fn relative_venv_path(add_default: bool) -> PathBuf {
197    let mut path = PathBuf::new();
198    if add_default {
199        path.push(DEFAULT_VENV_DIR);
200    }
201    path.push("bin");
202    path.push("python");
203    path
204}
205
206/// Returns the path to the activated virtual environment's executable.
207///
208/// A virtual environment is determined to be activated based on the
209/// existence of the `VIRTUAL_ENV` environment variable.
210fn venv_executable_path(venv_root: &str) -> PathBuf {
211    PathBuf::from(venv_root).join(relative_venv_path(false))
212}
213
214fn activated_venv() -> Option<PathBuf> {
215    log::info!("Checking for VIRTUAL_ENV environment variable");
216    env::var_os("VIRTUAL_ENV").map(|venv_root| {
217        log::debug!("VIRTUAL_ENV set to {venv_root:?}");
218        venv_executable_path(&venv_root.to_string_lossy())
219    })
220}
221
222fn venv_path_search() -> Option<PathBuf> {
223    if env::current_dir().is_err() {
224        log::warn!("current working directory is invalid");
225        None
226    } else {
227        let cwd = env::current_dir().unwrap();
228        let printable_cwd = cwd.display();
229        log::info!("Searching for a venv in {printable_cwd} and parent directories");
230        cwd.ancestors().find_map(|path| {
231            let venv_path = path.join(relative_venv_path(true));
232            let printable_venv_path = venv_path.display();
233            log::info!("Checking {printable_venv_path}");
234            // bool::then_some() makes more sense, but still experimental.
235            venv_path.is_file().then_some(venv_path)
236        })
237    }
238}
239
240fn venv_executable() -> Option<PathBuf> {
241    activated_venv().or_else(venv_path_search)
242}
243
244// https://en.m.wikipedia.org/wiki/Shebang_(Unix)
245fn parse_python_shebang(reader: &mut impl Read) -> Option<RequestedVersion> {
246    let mut shebang_buffer = [0; 2];
247    log::info!("Looking for a Python-related shebang");
248    if reader.read(&mut shebang_buffer).is_err() || shebang_buffer != [0x23, 0x21] {
249        // Doesn't start w/ `#!` in ASCII/UTF-8.
250        log::debug!("No '#!' at the start of the first line of the file");
251        return None;
252    }
253
254    let mut buffered_reader = BufReader::new(reader);
255    let mut first_line = String::new();
256
257    if buffered_reader.read_line(&mut first_line).is_err() {
258        log::debug!("Can't read first line of the file");
259        return None;
260    };
261
262    // Whitespace between `#!` and the path is allowed.
263    let line = first_line.trim();
264
265    let accepted_paths = [
266        "python",
267        "/usr/bin/python",
268        "/usr/local/bin/python",
269        "/usr/bin/env python",
270    ];
271
272    for acceptable_path in &accepted_paths {
273        if !line.starts_with(acceptable_path) {
274            continue;
275        }
276
277        log::debug!("Found shebang: {acceptable_path}");
278        let version = line[acceptable_path.len()..].to_string();
279        log::debug!("Found version: {version}");
280        return RequestedVersion::from_str(&version).ok();
281    }
282
283    None
284}
285
286fn find_executable(version: RequestedVersion, args: &[String]) -> crate::Result<PathBuf> {
287    let mut requested_version = version;
288    let mut chosen_path: Option<PathBuf> = None;
289
290    if requested_version == RequestedVersion::Any {
291        if let Some(venv_path) = venv_executable() {
292            chosen_path = Some(venv_path);
293        } else if !args.is_empty() {
294            // Using the first argument because it's the simplest and sanest.
295            // We can't use the last argument because that could actually be an argument
296            // to the Python module being executed. This is the same reason we can't go
297            // searching for the first/last file path that we find. The only safe way to
298            // get the file path regardless of its position is to replicate Python's arg
299            // parsing and that's a **lot** of work for little gain. Hence we only care
300            // about the first argument.
301            let possible_file = &args[0];
302            log::info!("Checking {possible_file:?} for a shebang");
303            if let Ok(mut open_file) = File::open(possible_file) {
304                if let Some(shebang_version) = parse_python_shebang(&mut open_file) {
305                    requested_version = shebang_version;
306                }
307            }
308        }
309    }
310
311    if chosen_path.is_none() {
312        if let Some(env_var) = requested_version.env_var() {
313            log::info!("Checking the {env_var} environment variable");
314            if let Ok(env_var_value) = env::var(&env_var) {
315                if !env_var_value.is_empty() {
316                    log::debug!("{env_var} = '{env_var_value}'");
317                    let env_requested_version = RequestedVersion::from_str(&env_var_value)?;
318                    requested_version = env_requested_version;
319                }
320            } else {
321                log::info!("{env_var} not set");
322            };
323        }
324
325        if let Some(executable_path) = crate::find_executable(requested_version) {
326            chosen_path = Some(executable_path);
327        }
328    }
329
330    chosen_path.ok_or(crate::Error::NoExecutableFound(requested_version))
331}
332
333#[cfg(test)]
334mod tests {
335    use test_case::test_case;
336
337    use super::*;
338
339    #[test_case(&["py".to_string(), "--help".to_string(), "--list".to_string()] => Err(crate::Error::IllegalArgument(PathBuf::from("py"), "--help".to_string())))]
340    #[test_case(&["py".to_string(), "--list".to_string(), "--help".to_string()] => Err(crate::Error::IllegalArgument(PathBuf::from("py"), "--list".to_string())))]
341    fn from_main_illegal_argument_tests(argv: &[String]) -> crate::Result<Action> {
342        Action::from_main(argv)
343    }
344
345    #[test_case("-S" => None ; "unrecognized short flag is None")]
346    #[test_case("--something" => None ; "unrecognized long flag is None")]
347    #[test_case("-3" => Some(RequestedVersion::MajorOnly(3)) ; "major version")]
348    #[test_case("-3.6" => Some(RequestedVersion::Exact(3, 6)) ; "Exact/major.minor")]
349    #[test_case("-42.13" => Some(RequestedVersion::Exact(42, 13)) ; "double-digit major & minor versions")]
350    #[test_case("-3.6.4" => None ; "version flag with micro version is None")]
351    fn version_from_flag_tests(flag: &str) -> Option<RequestedVersion> {
352        version_from_flag(flag)
353    }
354
355    #[test]
356    fn test_help_message() {
357        let launcher_path = "/some/path/to/launcher";
358        let python_path = "/a/path/to/python";
359
360        let help = help_message(&PathBuf::from(launcher_path), &PathBuf::from(python_path));
361        assert!(help.contains(env!("CARGO_PKG_VERSION")));
362        assert!(help.contains(launcher_path));
363        assert!(help.contains(python_path));
364    }
365
366    #[test]
367    fn test_list_executables() {
368        let mut executables: HashMap<ExactVersion, PathBuf> = HashMap::new();
369
370        assert_eq!(
371            list_executables(&executables),
372            Err(crate::Error::NoExecutableFound(RequestedVersion::Any))
373        );
374
375        let python27_path = "/path/to/2/7/python";
376        executables.insert(
377            ExactVersion { major: 2, minor: 7 },
378            PathBuf::from(python27_path),
379        );
380        let python36_path = "/path/to/3/6/python";
381        executables.insert(
382            ExactVersion { major: 3, minor: 6 },
383            PathBuf::from(python36_path),
384        );
385        let python37_path = "/path/to/3/7/python";
386        executables.insert(
387            ExactVersion { major: 3, minor: 7 },
388            PathBuf::from(python37_path),
389        );
390
391        // Tests try not to make any guarantees about explicit formatting, just
392        // that the interpreters are in descending order of version and the
393        // interpreter version comes before the path (i.e. in column order).
394        let executables_list = list_executables(&executables).unwrap();
395        // No critical data is missing.
396        assert!(executables_list.contains("2.7"));
397        assert!(executables_list.contains(python27_path));
398        assert!(executables_list.contains("3.6"));
399        assert!(executables_list.contains(python36_path));
400        assert!(executables_list.contains("3.7"));
401        assert!(executables_list.contains(python37_path));
402
403        // Interpreters listed in the expected order.
404        assert!(executables_list.find("3.7").unwrap() < executables_list.find("3.6").unwrap());
405        assert!(executables_list.find("3.6").unwrap() < executables_list.find("2.7").unwrap());
406
407        // Columns are in the expected order.
408        assert!(
409            executables_list.find("3.6").unwrap() < executables_list.find(python36_path).unwrap()
410        );
411        assert!(
412            executables_list.find("3.7").unwrap() < executables_list.find(python36_path).unwrap()
413        );
414    }
415
416    #[test]
417    fn test_venv_executable_path() {
418        let venv_root = "/path/to/venv";
419        assert_eq!(
420            venv_executable_path(venv_root),
421            PathBuf::from("/path/to/venv/bin/python")
422        );
423    }
424
425    #[test_case("/usr/bin/python" => None ; "missing shebang comment")]
426    #[test_case("# /usr/bin/python" => None ; "missing exclamation point")]
427    #[test_case("! /usr/bin/python" => None ; "missing octothorpe")]
428    #[test_case("#! /bin/sh" => None ; "non-Python shebang")]
429    #[test_case("#! /usr/bin/env python" => Some(RequestedVersion::Any) ; "typical 'env python'")]
430    #[test_case("#! /usr/bin/python" => Some(RequestedVersion::Any) ; "typical 'python'")]
431    #[test_case("#! /usr/local/bin/python" => Some(RequestedVersion::Any) ; "/usr/local")]
432    #[test_case("#! python" => Some(RequestedVersion::Any) ; "bare 'python'")]
433    #[test_case("#! /usr/bin/env python3.7" => Some(RequestedVersion::Exact(3, 7)) ; "typical 'env python' with minor version")]
434    #[test_case("#! /usr/bin/python3.7" => Some(RequestedVersion::Exact(3, 7)) ; "typical 'python' with minor version")]
435    #[test_case("#! python3.7" => Some(RequestedVersion::Exact(3, 7)) ; "bare 'python' with minor version")]
436    #[test_case("#!/usr/bin/python" => Some(RequestedVersion::Any) ; "no space between shebang and path")]
437    fn parse_python_shebang_tests(shebang: &str) -> Option<RequestedVersion> {
438        parse_python_shebang(&mut shebang.as_bytes())
439    }
440
441    #[test_case(&[0x23, 0x21, 0xc0, 0xaf] => None ; "invalid UTF-8")]
442    fn parse_python_sheban_include_invalid_bytes_tests(
443        mut shebang: &[u8],
444    ) -> Option<RequestedVersion> {
445        parse_python_shebang(&mut shebang)
446    }
447}