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.
56use 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};
1617use comfy_table::{Table, TableComponent};
1819use crate::{ExactVersion, RequestedVersion};
2021/// The expected directory name for virtual environments.
22pub static DEFAULT_VENV_DIR: &str = ".venv";
2324/// 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.
32Help(String, PathBuf),
33/// A string listing all found executables on `PATH`.
34 ///
35 /// The string is formatted to be human-readable.
36List(String),
37/// Details for executing a Python executable.
38Execute {
39/// The Python Launcher used to find the Python executable.
40launcher_path: PathBuf,
41/// The Python executable to run.
42executable: PathBuf,
43/// Arguments to the executable.
44args: Vec<String>,
45 },
46}
4748impl 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.
106pub fn from_main(argv: &[String]) -> crate::Result<Self> {
107let launcher_path = PathBuf::from(&argv[0]); // Strip the path to this executable.
108109match argv.get(1) {
110Some(flag) if flag == "-h" || flag == "--help" || flag == "--list" => {
111if argv.len() > 2 {
112Err(crate::Error::IllegalArgument(
113 launcher_path,
114 flag.to_string(),
115 ))
116 } else if flag == "--list" {
117Ok(Action::List(list_executables(&crate::all_executables())?))
118 } else {
119crate::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 }
129Some(version) if version_from_flag(version).is_some() => {
130Ok(Action::Execute {
131 launcher_path,
132// Make sure to skip the app path and version specification.
133executable: find_executable(version_from_flag(version).unwrap(), &argv[2..])?,
134 args: argv[2..].to_vec(),
135 })
136 }
137Some(_) | None => Ok(Action::Execute {
138 launcher_path,
139// Make sure to skip the app path.
140executable: find_executable(RequestedVersion::Any, &argv[1..])?,
141 args: argv[1..].to_vec(),
142 }),
143 }
144 }
145}
146147fn help_message(launcher_path: &Path, executable_path: &Path) -> String {
148let mut message = String::new();
149writeln!(
150 message,
151include_str!("HELP.txt"),
152env!("CARGO_PKG_VERSION"),
153 launcher_path.to_string_lossy(),
154 executable_path.to_string_lossy()
155 )
156 .unwrap();
157 message
158}
159160/// 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> {
165if !arg.starts_with('-') {
166None
167} else {
168 RequestedVersion::from_str(&arg[1..]).ok()
169 }
170}
171172fn list_executables(executables: &HashMap<ExactVersion, PathBuf>) -> crate::Result<String> {
173if executables.is_empty() {
174return Err(crate::Error::NoExecutableFound(RequestedVersion::Any));
175 }
176177let mut executable_pairs = Vec::from_iter(executables);
178 executable_pairs.sort_unstable();
179 executable_pairs.reverse();
180181let 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.
187table.set_style(TableComponent::VerticalLines, '│');
188189for (version, path) in executable_pairs {
190 table.add_row(vec![version.to_string(), path.display().to_string()]);
191 }
192193Ok(table.to_string() + "\n")
194}
195196fn relative_venv_path(add_default: bool) -> PathBuf {
197let mut path = PathBuf::new();
198if add_default {
199 path.push(DEFAULT_VENV_DIR);
200 }
201 path.push("bin");
202 path.push("python");
203 path
204}
205206/// 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}
213214fn activated_venv() -> Option<PathBuf> {
215log::info!("Checking for VIRTUAL_ENV environment variable");
216 env::var_os("VIRTUAL_ENV").map(|venv_root| {
217log::debug!("VIRTUAL_ENV set to {venv_root:?}");
218 venv_executable_path(&venv_root.to_string_lossy())
219 })
220}
221222fn venv_path_search() -> Option<PathBuf> {
223if env::current_dir().is_err() {
224log::warn!("current working directory is invalid");
225None
226} else {
227let cwd = env::current_dir().unwrap();
228let printable_cwd = cwd.display();
229log::info!("Searching for a venv in {printable_cwd} and parent directories");
230 cwd.ancestors().find_map(|path| {
231let venv_path = path.join(relative_venv_path(true));
232let printable_venv_path = venv_path.display();
233log::info!("Checking {printable_venv_path}");
234// bool::then_some() makes more sense, but still experimental.
235venv_path.is_file().then_some(venv_path)
236 })
237 }
238}
239240fn venv_executable() -> Option<PathBuf> {
241 activated_venv().or_else(venv_path_search)
242}
243244// https://en.m.wikipedia.org/wiki/Shebang_(Unix)
245fn parse_python_shebang(reader: &mut impl Read) -> Option<RequestedVersion> {
246let mut shebang_buffer = [0; 2];
247log::info!("Looking for a Python-related shebang");
248if reader.read(&mut shebang_buffer).is_err() || shebang_buffer != [0x23, 0x21] {
249// Doesn't start w/ `#!` in ASCII/UTF-8.
250log::debug!("No '#!' at the start of the first line of the file");
251return None;
252 }
253254let mut buffered_reader = BufReader::new(reader);
255let mut first_line = String::new();
256257if buffered_reader.read_line(&mut first_line).is_err() {
258log::debug!("Can't read first line of the file");
259return None;
260 };
261262// Whitespace between `#!` and the path is allowed.
263let line = first_line.trim();
264265let accepted_paths = [
266"python",
267"/usr/bin/python",
268"/usr/local/bin/python",
269"/usr/bin/env python",
270 ];
271272for acceptable_path in &accepted_paths {
273if !line.starts_with(acceptable_path) {
274continue;
275 }
276277log::debug!("Found shebang: {acceptable_path}");
278let version = line[acceptable_path.len()..].to_string();
279log::debug!("Found version: {version}");
280return RequestedVersion::from_str(&version).ok();
281 }
282283None
284}
285286fn find_executable(version: RequestedVersion, args: &[String]) -> crate::Result<PathBuf> {
287let mut requested_version = version;
288let mut chosen_path: Option<PathBuf> = None;
289290if requested_version == RequestedVersion::Any {
291if 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.
301let possible_file = &args[0];
302log::info!("Checking {possible_file:?} for a shebang");
303if let Ok(mut open_file) = File::open(possible_file) {
304if let Some(shebang_version) = parse_python_shebang(&mut open_file) {
305 requested_version = shebang_version;
306 }
307 }
308 }
309 }
310311if chosen_path.is_none() {
312if let Some(env_var) = requested_version.env_var() {
313log::info!("Checking the {env_var} environment variable");
314if let Ok(env_var_value) = env::var(&env_var) {
315if !env_var_value.is_empty() {
316log::debug!("{env_var} = '{env_var_value}'");
317let env_requested_version = RequestedVersion::from_str(&env_var_value)?;
318 requested_version = env_requested_version;
319 }
320 } else {
321log::info!("{env_var} not set");
322 };
323 }
324325if let Some(executable_path) = crate::find_executable(requested_version) {
326 chosen_path = Some(executable_path);
327 }
328 }
329330 chosen_path.ok_or(crate::Error::NoExecutableFound(requested_version))
331}
332333#[cfg(test)]
334mod tests {
335use test_case::test_case;
336337use super::*;
338339#[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())))]
341fn from_main_illegal_argument_tests(argv: &[String]) -> crate::Result<Action> {
342 Action::from_main(argv)
343 }
344345#[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")]
351fn version_from_flag_tests(flag: &str) -> Option<RequestedVersion> {
352 version_from_flag(flag)
353 }
354355#[test]
356fn test_help_message() {
357let launcher_path = "/some/path/to/launcher";
358let python_path = "/a/path/to/python";
359360let help = help_message(&PathBuf::from(launcher_path), &PathBuf::from(python_path));
361assert!(help.contains(env!("CARGO_PKG_VERSION")));
362assert!(help.contains(launcher_path));
363assert!(help.contains(python_path));
364 }
365366#[test]
367fn test_list_executables() {
368let mut executables: HashMap<ExactVersion, PathBuf> = HashMap::new();
369370assert_eq!(
371 list_executables(&executables),
372Err(crate::Error::NoExecutableFound(RequestedVersion::Any))
373 );
374375let python27_path = "/path/to/2/7/python";
376 executables.insert(
377 ExactVersion { major: 2, minor: 7 },
378 PathBuf::from(python27_path),
379 );
380let python36_path = "/path/to/3/6/python";
381 executables.insert(
382 ExactVersion { major: 3, minor: 6 },
383 PathBuf::from(python36_path),
384 );
385let python37_path = "/path/to/3/7/python";
386 executables.insert(
387 ExactVersion { major: 3, minor: 7 },
388 PathBuf::from(python37_path),
389 );
390391// 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).
394let executables_list = list_executables(&executables).unwrap();
395// No critical data is missing.
396assert!(executables_list.contains("2.7"));
397assert!(executables_list.contains(python27_path));
398assert!(executables_list.contains("3.6"));
399assert!(executables_list.contains(python36_path));
400assert!(executables_list.contains("3.7"));
401assert!(executables_list.contains(python37_path));
402403// Interpreters listed in the expected order.
404assert!(executables_list.find("3.7").unwrap() < executables_list.find("3.6").unwrap());
405assert!(executables_list.find("3.6").unwrap() < executables_list.find("2.7").unwrap());
406407// Columns are in the expected order.
408assert!(
409 executables_list.find("3.6").unwrap() < executables_list.find(python36_path).unwrap()
410 );
411assert!(
412 executables_list.find("3.7").unwrap() < executables_list.find(python36_path).unwrap()
413 );
414 }
415416#[test]
417fn test_venv_executable_path() {
418let venv_root = "/path/to/venv";
419assert_eq!(
420 venv_executable_path(venv_root),
421 PathBuf::from("/path/to/venv/bin/python")
422 );
423 }
424425#[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")]
437fn parse_python_shebang_tests(shebang: &str) -> Option<RequestedVersion> {
438 parse_python_shebang(&mut shebang.as_bytes())
439 }
440441#[test_case(&[0x23, 0x21, 0xc0, 0xaf] => None ; "invalid UTF-8")]
442fn parse_python_sheban_include_invalid_bytes_tests(
443mut shebang: &[u8],
444 ) -> Option<RequestedVersion> {
445 parse_python_shebang(&mut shebang)
446 }
447}