Skip to content

Commit 58117a6

Browse files
karthiknadigCopilot
andcommitted
fix: discover free-threaded Python executables (Fixes #569)
The executable-name patterns accepted `python3.13` but not the free-threaded `python3.13t`, so a distro build such as Fedora's /usr/bin/python3.13t (python3.13-freethreading) was never discovered. Accept a `t` suffix after the version. CPython's `make install` of a free-threaded build hard-links `python3.13` to `python3.13t`. Hard links resolve to different paths, so treat files with the same device and inode as the same interpreter (new `pet_fs::path::is_same_file`) in the Linux global locator and in `find_symlinks`; otherwise those builds would now be reported twice. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4ca7c03 commit 58117a6

4 files changed

Lines changed: 160 additions & 6 deletions

File tree

‎crates/pet-fs/src/path.rs‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,31 @@ pub fn resolve_symlink<T: AsRef<Path>>(exe: &T) -> Option<PathBuf> {
413413
}
414414
}
415415

416+
/// Returns `true` when both paths refer to the same file, following symlinks.
417+
///
418+
/// On Unix this compares the device and inode, so hard links to the same file match even though
419+
/// their paths and resolved paths differ. For example, CPython's `make install` of a free-threaded
420+
/// build hard-links `python3.13` to `python3.13t`. Elsewhere the canonical paths are compared.
421+
///
422+
/// Returns `false` when either path cannot be read.
423+
pub fn is_same_file<A: AsRef<Path>, B: AsRef<Path>>(first: A, second: B) -> bool {
424+
#[cfg(unix)]
425+
{
426+
use std::os::unix::fs::MetadataExt;
427+
match (std::fs::metadata(first), std::fs::metadata(second)) {
428+
(Ok(first), Ok(second)) => first.dev() == second.dev() && first.ino() == second.ino(),
429+
_ => false,
430+
}
431+
}
432+
#[cfg(not(unix))]
433+
{
434+
match (std::fs::canonicalize(first), std::fs::canonicalize(second)) {
435+
(Ok(first), Ok(second)) => first == second,
436+
_ => false,
437+
}
438+
}
439+
}
440+
416441
/// Expands `~` (home directory) and environment variables in a path.
417442
///
418443
/// This function handles:
@@ -991,4 +1016,30 @@ mod tests {
9911016
let _ = std::fs::remove_file(&symlink_path);
9921017
let _ = std::fs::remove_file(&target_file);
9931018
}
1019+
1020+
// ==================== is_same_file tests ====================
1021+
1022+
#[test]
1023+
fn test_is_same_file_matches_hard_links_and_symlinks() {
1024+
let dir = tempfile::tempdir().unwrap();
1025+
let original = dir.path().join("python3.13t");
1026+
let hard_link = dir.path().join("python3.13");
1027+
let other = dir.path().join("python3.12");
1028+
std::fs::write(&original, "python").unwrap();
1029+
std::fs::write(&other, "python").unwrap();
1030+
std::fs::hard_link(&original, &hard_link).unwrap();
1031+
1032+
assert!(is_same_file(&original, &original));
1033+
#[cfg(unix)]
1034+
assert!(is_same_file(&original, &hard_link));
1035+
assert!(!is_same_file(&original, &other));
1036+
assert!(!is_same_file(&original, dir.path().join("missing")));
1037+
1038+
#[cfg(unix)]
1039+
{
1040+
let symlink = dir.path().join("python3");
1041+
std::os::unix::fs::symlink(&hard_link, &symlink).unwrap();
1042+
assert!(is_same_file(&symlink, &original));
1043+
}
1044+
}
9941045
}

‎crates/pet-linux-global-python/src/lib.rs‎

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use pet_core::{
1717
reporter::Reporter,
1818
Locator, LocatorKind, RefreshStatePersistence,
1919
};
20-
use pet_fs::path::resolve_symlink;
20+
use pet_fs::path::{is_same_file, resolve_symlink};
2121
use pet_python_utils::{env::ResolvedPythonEnv, executable::find_executables};
2222
use pet_virtualenv::is_virtualenv;
2323

@@ -244,6 +244,13 @@ fn get_python_in_bin(env: &PythonEnv, is_64bit: bool) -> Option<PythonEnvironmen
244244
symlinks.push(possible_symlink.to_owned());
245245
}
246246
}
247+
248+
// A hard link to the executable in the same folder is the same interpreter under another
249+
// name. CPython's `make install` of a free-threaded build hard-links `python3.13` to
250+
// `python3.13t`.
251+
if !possible_symlink.is_symlink() && is_same_file(possible_symlink, &executable) {
252+
symlinks.push(possible_symlink.to_owned());
253+
}
247254
}
248255
symlinks.sort();
249256
symlinks.dedup();
@@ -389,6 +396,44 @@ mod tests {
389396
assert!(!symlinks.contains(&real_executable));
390397
}
391398

399+
#[cfg(unix)]
400+
#[test]
401+
fn get_python_in_bin_collects_hard_linked_free_threaded_executable() {
402+
// CPython's `make install` of a free-threaded build hard-links python3.13 to python3.13t.
403+
let dir = tempdir().unwrap();
404+
let bin = dir.path().join("bin");
405+
fs::create_dir(&bin).unwrap();
406+
let free_threaded = bin.join("python3.13t");
407+
let executable = bin.join("python3.13");
408+
create_executable(&free_threaded);
409+
fs::hard_link(&free_threaded, &executable).unwrap();
410+
let env = create_env(executable.clone(), dir.path().to_path_buf());
411+
412+
let symlinks = get_python_in_bin(&env, true).unwrap().symlinks.unwrap();
413+
414+
assert!(symlinks.contains(&executable));
415+
assert!(symlinks.contains(&free_threaded));
416+
}
417+
418+
#[cfg(unix)]
419+
#[test]
420+
fn get_python_in_bin_keeps_separate_free_threaded_binary_separate() {
421+
// Distributions such as Fedora ship the free-threaded build as a separate binary.
422+
let dir = tempdir().unwrap();
423+
let bin = dir.path().join("bin");
424+
fs::create_dir(&bin).unwrap();
425+
let free_threaded = bin.join("python3.13t");
426+
let executable = bin.join("python3.13");
427+
create_executable(&free_threaded);
428+
create_executable(&executable);
429+
let env = create_env(executable.clone(), dir.path().to_path_buf());
430+
431+
let symlinks = get_python_in_bin(&env, true).unwrap().symlinks.unwrap();
432+
433+
assert!(symlinks.contains(&executable));
434+
assert!(!symlinks.contains(&free_threaded));
435+
}
436+
392437
#[test]
393438
fn try_from_returns_none_without_version_before_cache_lookup() {
394439
let locator = LinuxGlobalPython::new();

‎crates/pet-python-utils/src/executable.rs‎

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@ use std::{
1111
};
1212

1313
lazy_static! {
14-
static ref WINDOWS_EXE: Regex =
15-
Regex::new(r"python(\d+\.?)*\.exe$").expect("error parsing Windows executable regex");
14+
// `python`, `python3`, `python3.13`, and free-threaded builds such as `python3.13t`.
15+
static ref WINDOWS_EXE: Regex = Regex::new(r"python(\d+(\.\d+)*t?)?\.exe$")
16+
.expect("error parsing Windows executable regex");
1617
static ref UNIX_EXE: Regex =
17-
Regex::new(r"python(\d+\.?)*$").expect("error parsing Unix executable regex");
18+
Regex::new(r"python(\d+(\.\d+)*t?)?$").expect("error parsing Unix executable regex");
1819
}
1920

2021
/// Checks if a path is a broken symlink (symlink that points to a non-existent target).
@@ -301,6 +302,15 @@ mod tests {
301302
assert!(is_python_executable_name(
302303
PathBuf::from("python4.10").as_path()
303304
));
305+
// Free-threaded builds.
306+
#[cfg(unix)]
307+
assert!(is_python_executable_name(
308+
PathBuf::from("python3.13t").as_path()
309+
));
310+
#[cfg(unix)]
311+
assert!(is_python_executable_name(
312+
PathBuf::from("python3t").as_path()
313+
));
304314

305315
#[cfg(windows)]
306316
assert!(is_python_executable_name(
@@ -322,6 +332,11 @@ mod tests {
322332
assert!(is_python_executable_name(
323333
PathBuf::from("python4.10.exe").as_path()
324334
));
335+
// Free-threaded builds.
336+
#[cfg(windows)]
337+
assert!(is_python_executable_name(
338+
PathBuf::from("python3.14t.exe").as_path()
339+
));
325340
}
326341
#[test]
327342
fn is_not_python_executable_test() {
@@ -333,6 +348,18 @@ mod tests {
333348
assert!(!is_python_executable_name(
334349
PathBuf::from("pythonw3").as_path()
335350
));
351+
#[cfg(unix)]
352+
assert!(!is_python_executable_name(
353+
PathBuf::from("pythont").as_path()
354+
));
355+
#[cfg(unix)]
356+
assert!(!is_python_executable_name(
357+
PathBuf::from("python3.13t-config").as_path()
358+
));
359+
#[cfg(unix)]
360+
assert!(!is_python_executable_name(
361+
PathBuf::from("python3.13-config").as_path()
362+
));
336363

337364
#[cfg(windows)]
338365
assert!(!is_python_executable_name(
@@ -343,6 +370,10 @@ mod tests {
343370
PathBuf::from("pythonw3.exe").as_path()
344371
));
345372
#[cfg(windows)]
373+
assert!(!is_python_executable_name(
374+
PathBuf::from("pythonw3.14t.exe").as_path()
375+
));
376+
#[cfg(windows)]
346377
assert!(!is_python_executable_name(
347378
PathBuf::from("python.exe.__target__").as_path()
348379
));

‎crates/pet/src/locators.rs‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ fn find_symlinks(executable: &PathBuf) -> Option<Vec<PathBuf>> {
232232
// We use canonicalize to get the real path of the symlink.
233233
// Only used in this case, see notes for resolve_symlink.
234234

235-
use pet_fs::path::resolve_symlink;
235+
use pet_fs::path::{is_same_file, resolve_symlink};
236236
use pet_python_utils::executable::find_executables;
237237
use std::fs;
238238

@@ -248,7 +248,9 @@ fn find_symlinks(executable: &PathBuf) -> Option<Vec<PathBuf>> {
248248
let mut symlinks = vec![];
249249
for exe in find_executables(bin) {
250250
let symlink = resolve_symlink(&exe).or(fs::canonicalize(&exe).ok());
251-
if symlink == real_exe {
251+
// Hard links resolve to different paths but are the same file, for example python3.13 and
252+
// the free-threaded python3.13t from CPython's `make install`.
253+
if symlink == real_exe || is_same_file(&exe, executable) {
252254
symlinks.push(exe);
253255
}
254256
}
@@ -291,4 +293,29 @@ mod tests {
291293
assert!(result.is_none());
292294
assert_eq!(calls.get(), 1);
293295
}
296+
297+
#[cfg(unix)]
298+
#[test]
299+
fn find_symlinks_includes_hard_linked_free_threaded_executable() {
300+
// CPython's `make install` of a free-threaded build hard-links python3.13 to python3.13t
301+
// and symlinks python3 to python3.13.
302+
let dir = tempfile::tempdir().unwrap();
303+
let bin = dir.path().join("bin");
304+
std::fs::create_dir(&bin).unwrap();
305+
let free_threaded = bin.join("python3.13t");
306+
let versioned = bin.join("python3.13");
307+
let python3 = bin.join("python3");
308+
let other = bin.join("python3.12");
309+
std::fs::write(&free_threaded, "").unwrap();
310+
std::fs::write(&other, "").unwrap();
311+
std::fs::hard_link(&free_threaded, &versioned).unwrap();
312+
std::os::unix::fs::symlink("python3.13", &python3).unwrap();
313+
314+
let symlinks = find_symlinks(&python3).unwrap();
315+
316+
assert!(symlinks.contains(&python3));
317+
assert!(symlinks.contains(&versioned));
318+
assert!(symlinks.contains(&free_threaded));
319+
assert!(!symlinks.contains(&other));
320+
}
294321
}

0 commit comments

Comments
 (0)