Skip to content

Commit d26de2e

Browse files
authored
test: add Mac Xcode locator coverage (#429)
Adds focused direct coverage for the Mac Xcode locator while continuing the #389 coverage work. - Adds deterministic tests for Xcode path matching, locator metadata, and non-macOS rejection. - Corrects MacXCode supported categories to report MacXCode. - Narrows Xcode path classification to Xcode*.app Python executables under the expected usr/bin or framework layouts. Validation: - cargo fmt --all - wsl bash -lc 'cd /mnt/c/GIT/projects/python-environment-tools && cargo test -p pet-mac-xcode' - wsl bash -lc 'cd /mnt/c/GIT/projects/python-environment-tools && cargo clippy --all -- -D warnings' Refs #389
1 parent 6072b60 commit d26de2e

1 file changed

Lines changed: 229 additions & 3 deletions

File tree

  • crates/pet-mac-xcode/src

crates/pet-mac-xcode/src/lib.rs

Lines changed: 229 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ impl Locator for MacXCode {
3131
LocatorKind::MacXCode
3232
}
3333
fn supported_categories(&self) -> Vec<PythonEnvironmentKind> {
34-
vec![PythonEnvironmentKind::MacCommandLineTools]
34+
vec![PythonEnvironmentKind::MacXCode]
3535
}
3636

3737
fn try_from(&self, env: &PythonEnv) -> Option<PythonEnvironment> {
@@ -49,8 +49,7 @@ impl Locator for MacXCode {
4949

5050
// Support for /Applications/Xcode.app/Contents/Developer/usr/bin/python3
5151
// /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)
52-
if !exe_str.starts_with("/Applications") && !exe_str.contains("Contents/Developer/usr/bin")
53-
{
52+
if !is_xcode_python_path(&exe_str) {
5453
return None;
5554
}
5655

@@ -231,3 +230,230 @@ impl Locator for MacXCode {
231230
// }
232231
}
233232
}
233+
234+
fn is_xcode_python_path(executable: &str) -> bool {
235+
let Some(rest) = executable.strip_prefix("/Applications/") else {
236+
return false;
237+
};
238+
239+
let Some(app_bundle) = rest.split('/').next() else {
240+
return false;
241+
};
242+
243+
if !app_bundle.starts_with("Xcode") || !app_bundle.ends_with(".app") {
244+
return false;
245+
}
246+
247+
let app_relative_path = &rest[app_bundle.len()..];
248+
if let Some(usr_bin_entry) = app_relative_path.strip_prefix("/Contents/Developer/usr/bin/") {
249+
return is_macos_python_executable_name(usr_bin_entry) && !usr_bin_entry.contains('/');
250+
}
251+
252+
let Some(framework_entry) = app_relative_path
253+
.strip_prefix("/Contents/Developer/Library/Frameworks/Python3.framework/Versions/")
254+
else {
255+
return false;
256+
};
257+
258+
let mut framework_parts = framework_entry.split('/');
259+
framework_parts
260+
.next()
261+
.is_some_and(is_macos_framework_version_dir)
262+
&& framework_parts.next() == Some("bin")
263+
&& framework_parts
264+
.next()
265+
.is_some_and(is_macos_python_executable_name)
266+
&& framework_parts.next().is_none()
267+
}
268+
269+
fn is_macos_python_executable_name(executable: &str) -> bool {
270+
if executable == "python" || executable == "python3" {
271+
return true;
272+
}
273+
274+
let Some(minor) = executable.strip_prefix("python3.") else {
275+
return false;
276+
};
277+
278+
!minor.is_empty() && minor.chars().all(|ch| ch.is_ascii_digit())
279+
}
280+
281+
fn is_macos_framework_version_dir(version: &str) -> bool {
282+
if version == "Current" {
283+
return true;
284+
}
285+
286+
let mut parts = version.split('.');
287+
parts
288+
.next()
289+
.is_some_and(|major| !major.is_empty() && major.chars().all(|ch| ch.is_ascii_digit()))
290+
&& parts
291+
.next()
292+
.is_some_and(|minor| !minor.is_empty() && minor.chars().all(|ch| ch.is_ascii_digit()))
293+
&& parts.next().is_none()
294+
}
295+
296+
#[cfg(test)]
297+
mod tests {
298+
use super::*;
299+
use pet_core::Locator;
300+
301+
#[test]
302+
fn locator_metadata_matches_xcode_kind() {
303+
let locator = MacXCode::new();
304+
305+
assert_eq!(locator.get_kind(), LocatorKind::MacXCode);
306+
assert_eq!(
307+
locator.supported_categories(),
308+
vec![PythonEnvironmentKind::MacXCode]
309+
);
310+
}
311+
312+
#[test]
313+
fn xcode_path_accepts_default_xcode_usr_bin_python() {
314+
assert!(is_xcode_python_path(
315+
"/Applications/Xcode.app/Contents/Developer/usr/bin/python3"
316+
));
317+
}
318+
319+
#[test]
320+
fn xcode_path_accepts_versioned_xcode_usr_bin_python() {
321+
assert!(is_xcode_python_path(
322+
"/Applications/Xcode_15.0.1.app/Contents/Developer/usr/bin/python3"
323+
));
324+
}
325+
326+
#[test]
327+
fn xcode_path_accepts_default_xcode_usr_bin_python_without_version() {
328+
assert!(is_xcode_python_path(
329+
"/Applications/Xcode.app/Contents/Developer/usr/bin/python"
330+
));
331+
}
332+
333+
#[test]
334+
fn xcode_path_accepts_default_xcode_usr_bin_versioned_python() {
335+
assert!(is_xcode_python_path(
336+
"/Applications/Xcode.app/Contents/Developer/usr/bin/python3.12"
337+
));
338+
}
339+
340+
#[test]
341+
fn xcode_path_accepts_framework_python_executable() {
342+
assert!(is_xcode_python_path(
343+
"/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9/bin/python3.9"
344+
));
345+
}
346+
347+
#[test]
348+
fn xcode_path_accepts_framework_python_executable_in_current_version_dir() {
349+
assert!(is_xcode_python_path(
350+
"/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/Current/bin/python3"
351+
));
352+
}
353+
354+
#[test]
355+
fn xcode_path_rejects_non_python_framework_path() {
356+
assert!(!is_xcode_python_path(
357+
"/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9/Resources/Info.plist"
358+
));
359+
}
360+
361+
#[test]
362+
fn xcode_path_rejects_python_config_script() {
363+
assert!(!is_xcode_python_path(
364+
"/Applications/Xcode.app/Contents/Developer/usr/bin/python-config"
365+
));
366+
}
367+
368+
#[test]
369+
fn xcode_path_rejects_versioned_python_config_script() {
370+
assert!(!is_xcode_python_path(
371+
"/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9/bin/python3.9-config"
372+
));
373+
}
374+
375+
#[test]
376+
fn xcode_path_rejects_framework_python_executable_in_invalid_version_dir() {
377+
assert!(!is_xcode_python_path(
378+
"/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/Foo/bin/python3"
379+
));
380+
}
381+
382+
#[test]
383+
fn xcode_path_rejects_framework_python_executable_in_patch_version_dir() {
384+
assert!(!is_xcode_python_path(
385+
"/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9.0/bin/python3"
386+
));
387+
}
388+
389+
#[test]
390+
fn xcode_path_rejects_multi_dot_python_executable_name() {
391+
assert!(!is_xcode_python_path(
392+
"/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9/bin/python3.9.0"
393+
));
394+
}
395+
396+
#[test]
397+
fn xcode_path_rejects_compact_python_version_name() {
398+
assert!(!is_xcode_python_path(
399+
"/Applications/Xcode.app/Contents/Developer/usr/bin/python312"
400+
));
401+
}
402+
403+
#[test]
404+
fn xcode_path_rejects_python_prefixed_tool() {
405+
assert!(!is_xcode_python_path(
406+
"/Applications/Xcode.app/Contents/Developer/usr/bin/pythonfoo"
407+
));
408+
}
409+
410+
#[test]
411+
fn xcode_path_rejects_unrelated_application_python() {
412+
assert!(!is_xcode_python_path(
413+
"/Applications/Other.app/Contents/MacOS/python3"
414+
));
415+
}
416+
417+
#[test]
418+
fn xcode_path_rejects_other_application_developer_python() {
419+
assert!(!is_xcode_python_path(
420+
"/Applications/Other.app/Contents/Developer/usr/bin/python3"
421+
));
422+
}
423+
424+
#[test]
425+
fn xcode_path_rejects_developer_path_outside_applications() {
426+
assert!(!is_xcode_python_path(
427+
"/tmp/Xcode.app/Contents/Developer/usr/bin/python3"
428+
));
429+
}
430+
431+
#[test]
432+
fn xcode_path_rejects_nested_developer_layout() {
433+
assert!(!is_xcode_python_path(
434+
"/Applications/Xcode.app/Nested.app/Contents/Developer/usr/bin/python3"
435+
));
436+
}
437+
438+
#[test]
439+
fn xcode_path_rejects_nested_usr_bin_entry() {
440+
assert!(!is_xcode_python_path(
441+
"/Applications/Xcode.app/Contents/Developer/usr/bin/nested/python3"
442+
));
443+
}
444+
445+
#[cfg(not(target_os = "macos"))]
446+
#[test]
447+
fn try_from_rejects_xcode_path_off_macos() {
448+
let locator = MacXCode::new();
449+
let env = PythonEnv::new(
450+
PathBuf::from("/Applications/Xcode.app/Contents/Developer/usr/bin/python3"),
451+
Some(PathBuf::from(
452+
"/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9",
453+
)),
454+
Some("3.9.6".to_string()),
455+
);
456+
457+
assert!(locator.try_from(&env).is_none());
458+
}
459+
}

0 commit comments

Comments
 (0)