1pub 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
31pub type Result<T> = std::result::Result<T, Error>;
33
34#[derive(Clone, Debug, PartialEq, Eq)]
36pub enum Error {
37 ParseVersionComponentError(ParseIntError, String),
39 DotMissing,
41 FileNameMissing,
43 FileNameToStrError,
45 PathFileNameError,
47 NoExecutableFound(RequestedVersion),
49 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 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
109pub type ComponentSize = u16;
111
112#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
118pub enum RequestedVersion {
119 Any,
121 MajorOnly(ComponentSize),
123 Exact(ComponentSize, ComponentSize),
125}
126
127impl Display for RequestedVersion {
128 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 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#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
207pub struct ExactVersion {
208 pub major: ComponentSize,
210 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 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 pub fn new(major: ComponentSize, minor: ComponentSize) -> Self {
267 ExactVersion { major, minor }
268 }
269
270 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 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 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()) .flatten() .filter_map(|e| e.ok()) .map(|e| e.path()) }
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
365pub 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
387pub 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] 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 assert_eq!(py3_10.cmp(&py3_10), Ordering::Equal);
454 assert_eq!(py3_0.cmp(&py3_6), Ordering::Less);
456 assert_eq!(py3_6.cmp(&py3_0), Ordering::Greater);
458 assert_eq!(py2_7.cmp(&py3_0), Ordering::Less);
460 assert_eq!(py3_0.cmp(&py2_7), Ordering::Greater);
461 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 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}