@@ -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