From f8dd2e954ccb23d076d05b7e931390464d3af99a Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Wed, 15 Apr 2026 16:13:38 -0700 Subject: [PATCH 1/3] test: add Mac Xcode locator coverage (Refs #389) --- crates/pet-mac-xcode/src/lib.rs | 141 ++++++++++++++++++++++++++++++-- 1 file changed, 136 insertions(+), 5 deletions(-) diff --git a/crates/pet-mac-xcode/src/lib.rs b/crates/pet-mac-xcode/src/lib.rs index ab86adeb..d80b74f8 100644 --- a/crates/pet-mac-xcode/src/lib.rs +++ b/crates/pet-mac-xcode/src/lib.rs @@ -31,7 +31,7 @@ impl Locator for MacXCode { LocatorKind::MacXCode } fn supported_categories(&self) -> Vec { - vec![PythonEnvironmentKind::MacCommandLineTools] + vec![PythonEnvironmentKind::MacXCode] } fn try_from(&self, env: &PythonEnv) -> Option { @@ -45,15 +45,14 @@ impl Locator for MacXCode { return None; } - let exe_str = env.executable.to_string_lossy(); - // Support for /Applications/Xcode.app/Contents/Developer/usr/bin/python3 // /Applications/Xcode_15.0.1.app/Contents/Developer/usr/bin/python3 (such paths are on CI, see here https://github.com/microsoft/python-environment-tools/issues/38) - if !exe_str.starts_with("/Applications") && !exe_str.contains("Contents/Developer/usr/bin") - { + if !is_xcode_python_path(&env.executable) { return None; } + let exe_str = env.executable.to_string_lossy(); + let mut version = env.version.clone(); let mut prefix = env.prefix.clone(); let mut symlinks = vec![env.executable.clone()]; @@ -231,3 +230,135 @@ impl Locator for MacXCode { // } } } + +fn is_xcode_python_path(executable: &std::path::Path) -> bool { + let executable = executable.to_string_lossy(); + let Some(rest) = executable.strip_prefix("/Applications/") else { + return false; + }; + + let Some(app_bundle) = rest.split('/').next() else { + return false; + }; + + if !app_bundle.starts_with("Xcode") || !app_bundle.ends_with(".app") { + return false; + } + + let app_relative_path = &rest[app_bundle.len()..]; + if let Some(usr_bin_entry) = app_relative_path.strip_prefix("/Contents/Developer/usr/bin/") { + return usr_bin_entry.starts_with("python") && !usr_bin_entry.contains('/'); + } + + let Some(framework_entry) = app_relative_path + .strip_prefix("/Contents/Developer/Library/Frameworks/Python3.framework/Versions/") + else { + return false; + }; + + let mut framework_parts = framework_entry.split('/'); + framework_parts + .next() + .is_some_and(|version| !version.is_empty()) + && framework_parts.next() == Some("bin") + && framework_parts + .next() + .is_some_and(|executable| executable.starts_with("python")) + && framework_parts.next().is_none() +} + +#[cfg(test)] +mod tests { + use super::*; + use pet_core::Locator; + use std::path::Path; + + #[test] + fn locator_metadata_matches_xcode_kind() { + let locator = MacXCode::new(); + + assert_eq!(locator.get_kind(), LocatorKind::MacXCode); + assert_eq!( + locator.supported_categories(), + vec![PythonEnvironmentKind::MacXCode] + ); + } + + #[test] + fn xcode_path_accepts_default_xcode_usr_bin_python() { + assert!(is_xcode_python_path(Path::new( + "/Applications/Xcode.app/Contents/Developer/usr/bin/python3" + ))); + } + + #[test] + fn xcode_path_accepts_versioned_xcode_usr_bin_python() { + assert!(is_xcode_python_path(Path::new( + "/Applications/Xcode_15.0.1.app/Contents/Developer/usr/bin/python3" + ))); + } + + #[test] + fn xcode_path_accepts_framework_python_executable() { + assert!(is_xcode_python_path(Path::new( + "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9/bin/python3.9" + ))); + } + + #[test] + fn xcode_path_rejects_non_python_framework_path() { + assert!(!is_xcode_python_path(Path::new( + "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9/Resources/Info.plist" + ))); + } + + #[test] + fn xcode_path_rejects_unrelated_application_python() { + assert!(!is_xcode_python_path(Path::new( + "/Applications/Other.app/Contents/MacOS/python3" + ))); + } + + #[test] + fn xcode_path_rejects_other_application_developer_python() { + assert!(!is_xcode_python_path(Path::new( + "/Applications/Other.app/Contents/Developer/usr/bin/python3" + ))); + } + + #[test] + fn xcode_path_rejects_developer_path_outside_applications() { + assert!(!is_xcode_python_path(Path::new( + "/tmp/Xcode.app/Contents/Developer/usr/bin/python3" + ))); + } + + #[test] + fn xcode_path_rejects_nested_developer_layout() { + assert!(!is_xcode_python_path(Path::new( + "/Applications/Xcode.app/Nested.app/Contents/Developer/usr/bin/python3" + ))); + } + + #[test] + fn xcode_path_rejects_nested_usr_bin_entry() { + assert!(!is_xcode_python_path(Path::new( + "/Applications/Xcode.app/Contents/Developer/usr/bin/nested/python3" + ))); + } + + #[cfg(not(target_os = "macos"))] + #[test] + fn try_from_rejects_xcode_path_off_macos() { + let locator = MacXCode::new(); + let env = PythonEnv::new( + PathBuf::from("/Applications/Xcode.app/Contents/Developer/usr/bin/python3"), + Some(PathBuf::from( + "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9", + )), + Some("3.9.6".to_string()), + ); + + assert!(locator.try_from(&env).is_none()); + } +} From d9c3850d88da03681ac8382a1faf3f9ce53bc022 Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Wed, 15 Apr 2026 16:22:57 -0700 Subject: [PATCH 2/3] fix: address Mac Xcode review feedback (PR #429) --- crates/pet-mac-xcode/src/lib.rs | 87 +++++++++++++++++++++++---------- 1 file changed, 61 insertions(+), 26 deletions(-) diff --git a/crates/pet-mac-xcode/src/lib.rs b/crates/pet-mac-xcode/src/lib.rs index d80b74f8..6d53baad 100644 --- a/crates/pet-mac-xcode/src/lib.rs +++ b/crates/pet-mac-xcode/src/lib.rs @@ -45,14 +45,14 @@ impl Locator for MacXCode { return None; } + let exe_str = env.executable.to_string_lossy(); + // Support for /Applications/Xcode.app/Contents/Developer/usr/bin/python3 // /Applications/Xcode_15.0.1.app/Contents/Developer/usr/bin/python3 (such paths are on CI, see here https://github.com/microsoft/python-environment-tools/issues/38) - if !is_xcode_python_path(&env.executable) { + if !is_xcode_python_path(&exe_str) { return None; } - let exe_str = env.executable.to_string_lossy(); - let mut version = env.version.clone(); let mut prefix = env.prefix.clone(); let mut symlinks = vec![env.executable.clone()]; @@ -231,8 +231,7 @@ impl Locator for MacXCode { } } -fn is_xcode_python_path(executable: &std::path::Path) -> bool { - let executable = executable.to_string_lossy(); +fn is_xcode_python_path(executable: &str) -> bool { let Some(rest) = executable.strip_prefix("/Applications/") else { return false; }; @@ -247,7 +246,7 @@ fn is_xcode_python_path(executable: &std::path::Path) -> bool { let app_relative_path = &rest[app_bundle.len()..]; if let Some(usr_bin_entry) = app_relative_path.strip_prefix("/Contents/Developer/usr/bin/") { - return usr_bin_entry.starts_with("python") && !usr_bin_entry.contains('/'); + return is_macos_python_executable_name(usr_bin_entry) && !usr_bin_entry.contains('/'); } let Some(framework_entry) = app_relative_path @@ -263,15 +262,30 @@ fn is_xcode_python_path(executable: &std::path::Path) -> bool { && framework_parts.next() == Some("bin") && framework_parts .next() - .is_some_and(|executable| executable.starts_with("python")) + .is_some_and(is_macos_python_executable_name) && framework_parts.next().is_none() } +fn is_macos_python_executable_name(executable: &str) -> bool { + let Some(version) = executable.strip_prefix("python") else { + return false; + }; + + if version.is_empty() { + return true; + } + + version.chars().any(|ch| ch.is_ascii_digit()) + && !version.starts_with('.') + && !version.ends_with('.') + && !version.contains("..") + && version.chars().all(|ch| ch.is_ascii_digit() || ch == '.') +} + #[cfg(test)] mod tests { use super::*; use pet_core::Locator; - use std::path::Path; #[test] fn locator_metadata_matches_xcode_kind() { @@ -286,65 +300,86 @@ mod tests { #[test] fn xcode_path_accepts_default_xcode_usr_bin_python() { - assert!(is_xcode_python_path(Path::new( + assert!(is_xcode_python_path( "/Applications/Xcode.app/Contents/Developer/usr/bin/python3" - ))); + )); } #[test] fn xcode_path_accepts_versioned_xcode_usr_bin_python() { - assert!(is_xcode_python_path(Path::new( + assert!(is_xcode_python_path( "/Applications/Xcode_15.0.1.app/Contents/Developer/usr/bin/python3" - ))); + )); } #[test] fn xcode_path_accepts_framework_python_executable() { - assert!(is_xcode_python_path(Path::new( + assert!(is_xcode_python_path( "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9/bin/python3.9" - ))); + )); } #[test] fn xcode_path_rejects_non_python_framework_path() { - assert!(!is_xcode_python_path(Path::new( + assert!(!is_xcode_python_path( "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9/Resources/Info.plist" - ))); + )); + } + + #[test] + fn xcode_path_rejects_python_config_script() { + assert!(!is_xcode_python_path( + "/Applications/Xcode.app/Contents/Developer/usr/bin/python-config" + )); + } + + #[test] + fn xcode_path_rejects_versioned_python_config_script() { + assert!(!is_xcode_python_path( + "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9/bin/python3.9-config" + )); + } + + #[test] + fn xcode_path_rejects_python_prefixed_tool() { + assert!(!is_xcode_python_path( + "/Applications/Xcode.app/Contents/Developer/usr/bin/pythonfoo" + )); } #[test] fn xcode_path_rejects_unrelated_application_python() { - assert!(!is_xcode_python_path(Path::new( + assert!(!is_xcode_python_path( "/Applications/Other.app/Contents/MacOS/python3" - ))); + )); } #[test] fn xcode_path_rejects_other_application_developer_python() { - assert!(!is_xcode_python_path(Path::new( + assert!(!is_xcode_python_path( "/Applications/Other.app/Contents/Developer/usr/bin/python3" - ))); + )); } #[test] fn xcode_path_rejects_developer_path_outside_applications() { - assert!(!is_xcode_python_path(Path::new( + assert!(!is_xcode_python_path( "/tmp/Xcode.app/Contents/Developer/usr/bin/python3" - ))); + )); } #[test] fn xcode_path_rejects_nested_developer_layout() { - assert!(!is_xcode_python_path(Path::new( + assert!(!is_xcode_python_path( "/Applications/Xcode.app/Nested.app/Contents/Developer/usr/bin/python3" - ))); + )); } #[test] fn xcode_path_rejects_nested_usr_bin_entry() { - assert!(!is_xcode_python_path(Path::new( + assert!(!is_xcode_python_path( "/Applications/Xcode.app/Contents/Developer/usr/bin/nested/python3" - ))); + )); } #[cfg(not(target_os = "macos"))] From 5aefd22f5b6c1a41198741199c83d386dd7c6aef Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Thu, 16 Apr 2026 22:07:00 -0700 Subject: [PATCH 3/3] fix: tighten Mac Xcode path matching (PR #429) --- crates/pet-mac-xcode/src/lib.rs | 76 +++++++++++++++++++++++++++++---- 1 file changed, 68 insertions(+), 8 deletions(-) diff --git a/crates/pet-mac-xcode/src/lib.rs b/crates/pet-mac-xcode/src/lib.rs index 6d53baad..9ed76de8 100644 --- a/crates/pet-mac-xcode/src/lib.rs +++ b/crates/pet-mac-xcode/src/lib.rs @@ -258,7 +258,7 @@ fn is_xcode_python_path(executable: &str) -> bool { let mut framework_parts = framework_entry.split('/'); framework_parts .next() - .is_some_and(|version| !version.is_empty()) + .is_some_and(is_macos_framework_version_dir) && framework_parts.next() == Some("bin") && framework_parts .next() @@ -267,19 +267,30 @@ fn is_xcode_python_path(executable: &str) -> bool { } fn is_macos_python_executable_name(executable: &str) -> bool { - let Some(version) = executable.strip_prefix("python") else { + if executable == "python" || executable == "python3" { + return true; + } + + let Some(minor) = executable.strip_prefix("python3.") else { return false; }; - if version.is_empty() { + !minor.is_empty() && minor.chars().all(|ch| ch.is_ascii_digit()) +} + +fn is_macos_framework_version_dir(version: &str) -> bool { + if version == "Current" { return true; } - version.chars().any(|ch| ch.is_ascii_digit()) - && !version.starts_with('.') - && !version.ends_with('.') - && !version.contains("..") - && version.chars().all(|ch| ch.is_ascii_digit() || ch == '.') + let mut parts = version.split('.'); + parts + .next() + .is_some_and(|major| !major.is_empty() && major.chars().all(|ch| ch.is_ascii_digit())) + && parts + .next() + .is_some_and(|minor| !minor.is_empty() && minor.chars().all(|ch| ch.is_ascii_digit())) + && parts.next().is_none() } #[cfg(test)] @@ -312,6 +323,20 @@ mod tests { )); } + #[test] + fn xcode_path_accepts_default_xcode_usr_bin_python_without_version() { + assert!(is_xcode_python_path( + "/Applications/Xcode.app/Contents/Developer/usr/bin/python" + )); + } + + #[test] + fn xcode_path_accepts_default_xcode_usr_bin_versioned_python() { + assert!(is_xcode_python_path( + "/Applications/Xcode.app/Contents/Developer/usr/bin/python3.12" + )); + } + #[test] fn xcode_path_accepts_framework_python_executable() { assert!(is_xcode_python_path( @@ -319,6 +344,13 @@ mod tests { )); } + #[test] + fn xcode_path_accepts_framework_python_executable_in_current_version_dir() { + assert!(is_xcode_python_path( + "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/Current/bin/python3" + )); + } + #[test] fn xcode_path_rejects_non_python_framework_path() { assert!(!is_xcode_python_path( @@ -340,6 +372,34 @@ mod tests { )); } + #[test] + fn xcode_path_rejects_framework_python_executable_in_invalid_version_dir() { + assert!(!is_xcode_python_path( + "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/Foo/bin/python3" + )); + } + + #[test] + fn xcode_path_rejects_framework_python_executable_in_patch_version_dir() { + assert!(!is_xcode_python_path( + "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9.0/bin/python3" + )); + } + + #[test] + fn xcode_path_rejects_multi_dot_python_executable_name() { + assert!(!is_xcode_python_path( + "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9/bin/python3.9.0" + )); + } + + #[test] + fn xcode_path_rejects_compact_python_version_name() { + assert!(!is_xcode_python_path( + "/Applications/Xcode.app/Contents/Developer/usr/bin/python312" + )); + } + #[test] fn xcode_path_rejects_python_prefixed_tool() { assert!(!is_xcode_python_path(