Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"git.branchProtectionPrompt": "alwaysCommitToNewBranch",
"git.branchRandomName.enable": true,
"chat.tools.terminal.autoApprove": {
"cargo test": true
"cargo test": true,
"cargo fmt": true
}
}
17 changes: 17 additions & 0 deletions crates/pet-core/src/python_environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ pub struct PythonEnvironment {
// Some of the known symlinks for the environment.
// E.g. in the case of Homebrew there are a number of symlinks that are created.
pub symlinks: Option<Vec<PathBuf>>,
/// An error message if the environment is known to be in a bad state.
/// For example, when the Python executable is a broken symlink.
/// If None, no known issues have been detected (but this doesn't guarantee
/// the environment is fully functional - we don't spawn Python to verify).
pub error: Option<String>,
}

impl Ord for PythonEnvironment {
Expand Down Expand Up @@ -176,6 +181,9 @@ impl std::fmt::Display for PythonEnvironment {
}
}
}
if let Some(error) = &self.error {
writeln!(f, " Error : {error}").unwrap_or_default();
}
Ok(())
}
}
Expand All @@ -194,6 +202,7 @@ pub struct PythonEnvironmentBuilder {
project: Option<PathBuf>,
arch: Option<Architecture>,
symlinks: Option<Vec<PathBuf>>,
error: Option<String>,
}

impl PythonEnvironmentBuilder {
Expand All @@ -209,6 +218,7 @@ impl PythonEnvironmentBuilder {
project: None,
arch: None,
symlinks: None,
error: None,
}
}
pub fn from_environment(env: PythonEnvironment) -> Self {
Expand All @@ -223,6 +233,7 @@ impl PythonEnvironmentBuilder {
project: env.project,
arch: env.arch,
symlinks: env.symlinks,
error: env.error,
}
}

Expand Down Expand Up @@ -285,6 +296,11 @@ impl PythonEnvironmentBuilder {
self
}

pub fn error(mut self, error: Option<String>) -> Self {
self.error = error;
self
}

fn update_symlinks_and_exe(&mut self, symlinks: Option<Vec<PathBuf>>) {
let mut all = self.symlinks.clone().unwrap_or_default();
if let Some(ref exe) = self.executable {
Expand Down Expand Up @@ -340,6 +356,7 @@ impl PythonEnvironmentBuilder {
project: self.project,
arch: self.arch,
symlinks,
error: self.error,
}
}
}
Expand Down
12 changes: 12 additions & 0 deletions crates/pet-pyenv/tests/pyenv_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ fn find_pyenv_envs() {
home.to_str().unwrap(),
".pyenv/versions/3.9.9/bin/python",
])]),
error: None,
};
let expected_virtual_env = PythonEnvironment {
display_name: None,
Expand All @@ -242,6 +243,7 @@ fn find_pyenv_envs() {
home.to_str().unwrap(),
".pyenv/versions/my-virtual-env/bin/python",
])]),
error: None,
};
let expected_3_12_1 = PythonEnvironment {
display_name: None,
Expand All @@ -263,6 +265,7 @@ fn find_pyenv_envs() {
home.to_str().unwrap(),
".pyenv/versions/3.12.1/bin/python",
])]),
error: None,
};
let expected_3_13_dev = PythonEnvironment {
display_name: None,
Expand All @@ -284,6 +287,7 @@ fn find_pyenv_envs() {
home.to_str().unwrap(),
".pyenv/versions/3.13-dev/bin/python",
])]),
error: None,
};
let expected_3_12_1a3 = PythonEnvironment {
display_name: None,
Expand All @@ -305,6 +309,7 @@ fn find_pyenv_envs() {
home.to_str().unwrap(),
".pyenv/versions/3.12.1a3/bin/python",
])]),
error: None,
};
let expected_no_gil = PythonEnvironment {
display_name: None,
Expand All @@ -326,6 +331,7 @@ fn find_pyenv_envs() {
home.to_str().unwrap(),
".pyenv/versions/nogil-3.9.10-1/bin/python",
])]),
error: None,
};
let expected_pypy = PythonEnvironment {
display_name: None,
Expand All @@ -347,6 +353,7 @@ fn find_pyenv_envs() {
home.to_str().unwrap(),
".pyenv/versions/pypy3.9-7.3.15/bin/python",
])]),
error: None,
};

let expected_conda_root = PythonEnvironment {
Expand All @@ -360,6 +367,7 @@ fn find_pyenv_envs() {
manager: Some(expected_conda_manager.clone()),
arch: Some(Architecture::X64),
symlinks: Some(vec![conda_dir.join("bin").join("python")]),
error: None,
};
let expected_conda_one = PythonEnvironment {
display_name: None,
Expand All @@ -372,6 +380,7 @@ fn find_pyenv_envs() {
manager: Some(expected_conda_manager.clone()),
arch: None,
symlinks: Some(vec![conda_dir.join("envs").join("one").join("python")]),
error: None,
};
let expected_conda_two = PythonEnvironment {
display_name: None,
Expand All @@ -384,6 +393,7 @@ fn find_pyenv_envs() {
manager: Some(expected_conda_manager.clone()),
symlinks: Some(vec![conda_dir.join("envs").join("two").join("python")]),
arch: None,
error: None,
};

let mut expected_envs = vec![
Expand Down Expand Up @@ -453,6 +463,7 @@ fn resolve_pyenv_environment() {
manager: Some(expected_manager.clone()),
arch: None,
symlinks: Some(vec![executable]),
error: None,
};
let expected_virtual_env = PythonEnvironment {
display_name: None,
Expand All @@ -474,6 +485,7 @@ fn resolve_pyenv_environment() {
home.to_str().unwrap(),
".pyenv/versions/my-virtual-env/bin/python",
])]),
error: None,
};

// Resolve regular Python installs in Pyenv
Expand Down
208 changes: 208 additions & 0 deletions crates/pet-python-utils/src/executable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,31 @@ lazy_static! {
Regex::new(r"python(\d+\.?)*$").expect("error parsing Unix executable regex");
}

/// Checks if a path is a broken symlink (symlink that points to a non-existent target).
/// Returns true if the path is a symlink and its target does not exist.
pub fn is_broken_symlink(path: &Path) -> bool {
// First check if it's a symlink using symlink_metadata (doesn't follow symlinks)
if let Ok(metadata) = fs::symlink_metadata(path) {
if metadata.file_type().is_symlink() {
// Now check if the target exists using regular metadata (follows symlinks)
// If this fails or returns false for exists(), then it's broken
return !path.exists();
}
}
false
}

/// Result of looking for an executable in an environment path.
#[derive(Debug, Clone)]
pub enum ExecutableResult {
/// A valid executable was found
Found(PathBuf),
/// An executable path exists but is broken (e.g., broken symlink)
Broken(PathBuf),
/// No executable was found
NotFound,
}

#[cfg(windows)]
pub fn find_executable(env_path: &Path) -> Option<PathBuf> {
[
Expand All @@ -43,6 +68,56 @@ pub fn find_executable(env_path: &Path) -> Option<PathBuf> {
.find(|path| path.is_file())
}

/// Finds an executable in the environment path, including broken symlinks.
/// This is useful for detecting virtual environments that have broken Python executables.
#[cfg(windows)]
pub fn find_executable_or_broken(env_path: &Path) -> ExecutableResult {
let candidates = [
env_path.join("Scripts").join("python.exe"),
env_path.join("Scripts").join("python3.exe"),
env_path.join("bin").join("python.exe"),
env_path.join("bin").join("python3.exe"),
env_path.join("python.exe"),
env_path.join("python3.exe"),
];

// First try to find a valid executable
if let Some(path) = candidates.iter().find(|path| path.is_file()) {
return ExecutableResult::Found(path.clone());
}

// Then check for broken symlinks
if let Some(path) = candidates.iter().find(|path| is_broken_symlink(path)) {
return ExecutableResult::Broken(path.clone());
}

ExecutableResult::NotFound
}

/// Finds an executable in the environment path, including broken symlinks.
/// This is useful for detecting virtual environments that have broken Python executables.
#[cfg(unix)]
pub fn find_executable_or_broken(env_path: &Path) -> ExecutableResult {
let candidates = [
env_path.join("bin").join("python"),
env_path.join("bin").join("python3"),
env_path.join("python"),
env_path.join("python3"),
];

// First try to find a valid executable
if let Some(path) = candidates.iter().find(|path| path.is_file()) {
return ExecutableResult::Found(path.clone());
}

// Then check for broken symlinks
if let Some(path) = candidates.iter().find(|path| is_broken_symlink(path)) {
return ExecutableResult::Broken(path.clone());
}

ExecutableResult::NotFound
}

pub fn find_executables<T: AsRef<Path>>(env_path: T) -> Vec<PathBuf> {
let mut env_path = env_path.as_ref().to_path_buf();
// Never find exes in pyenv shims folder, they are not valid exes.
Expand Down Expand Up @@ -306,4 +381,137 @@ mod tests {
PathBuf::from("/home/user/project/shims").as_path()
));
}

#[test]
fn test_is_broken_symlink_regular_file() {
// A regular file should not be detected as a broken symlink
let temp_dir = std::env::temp_dir();
let test_file = temp_dir.join("pet_test_regular_file.txt");
fs::write(&test_file, "test").unwrap();

assert!(!is_broken_symlink(&test_file));

let _ = fs::remove_file(&test_file);
}

#[test]
fn test_is_broken_symlink_nonexistent() {
// A non-existent path should not be detected as a broken symlink
let nonexistent = PathBuf::from("/this/path/does/not/exist/python");
assert!(!is_broken_symlink(&nonexistent));
}

#[test]
#[cfg(unix)]
fn test_is_broken_symlink_unix() {
use std::os::unix::fs::symlink;

let temp_dir = std::env::temp_dir();
let target = temp_dir.join("pet_test_symlink_target_nonexistent");
let link = temp_dir.join("pet_test_broken_symlink");

// Clean up any previous test artifacts
let _ = fs::remove_file(&link);
let _ = fs::remove_file(&target);

// Create a symlink to a non-existent target
symlink(&target, &link).unwrap();

// The symlink should be detected as broken
assert!(is_broken_symlink(&link));

// Clean up
let _ = fs::remove_file(&link);
}

#[test]
#[cfg(unix)]
fn test_is_broken_symlink_valid_symlink() {
use std::os::unix::fs::symlink;

let temp_dir = std::env::temp_dir();
let target = temp_dir.join("pet_test_symlink_target_exists");
let link = temp_dir.join("pet_test_valid_symlink");

// Clean up any previous test artifacts
let _ = fs::remove_file(&link);
let _ = fs::remove_file(&target);

// Create the target file
fs::write(&target, "test").unwrap();

// Create a symlink to the existing target
symlink(&target, &link).unwrap();

// The symlink should NOT be detected as broken
assert!(!is_broken_symlink(&link));

// Clean up
let _ = fs::remove_file(&link);
let _ = fs::remove_file(&target);
}

#[test]
fn test_find_executable_or_broken_not_found() {
let temp_dir = std::env::temp_dir().join("pet_test_empty_env");
let _ = fs::create_dir_all(&temp_dir);

match find_executable_or_broken(&temp_dir) {
ExecutableResult::NotFound => (),
other => panic!("Expected NotFound, got {:?}", other),
}

let _ = fs::remove_dir_all(&temp_dir);
}

#[test]
fn test_find_executable_or_broken_found() {
let temp_dir = std::env::temp_dir().join("pet_test_valid_env");
#[cfg(windows)]
let bin_dir = temp_dir.join("Scripts");
#[cfg(unix)]
let bin_dir = temp_dir.join("bin");

let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&bin_dir).unwrap();

#[cfg(windows)]
let python_exe = bin_dir.join("python.exe");
#[cfg(unix)]
let python_exe = bin_dir.join("python");

fs::write(&python_exe, "fake python").unwrap();

match find_executable_or_broken(&temp_dir) {
ExecutableResult::Found(path) => assert_eq!(path, python_exe),
other => panic!("Expected Found, got {:?}", other),
}

let _ = fs::remove_dir_all(&temp_dir);
}

#[test]
#[cfg(unix)]
fn test_find_executable_or_broken_broken_symlink() {
use std::os::unix::fs::symlink;

let temp_dir = std::env::temp_dir().join("pet_test_broken_env");
let bin_dir = temp_dir.join("bin");

let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&bin_dir).unwrap();

let python_exe = bin_dir.join("python");
let nonexistent_target = PathBuf::from("/nonexistent/python3.10");

// Create a broken symlink
symlink(&nonexistent_target, &python_exe).unwrap();

match find_executable_or_broken(&temp_dir) {
ExecutableResult::Broken(path) => assert_eq!(path, python_exe),
other => panic!("Expected Broken, got {:?}", other),
}

let _ = fs::remove_dir_all(&temp_dir);
}
}
Loading
Loading