Skip to content

Commit f2a59bf

Browse files
committed
feat: Enhance Python environment handling with error reporting for broken environments
1 parent 2df70d1 commit f2a59bf

File tree

5 files changed

+158
-1
lines changed

5 files changed

+158
-1
lines changed

crates/pet-core/src/python_environment.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@ pub struct PythonEnvironment {
7070
// Some of the known symlinks for the environment.
7171
// E.g. in the case of Homebrew there are a number of symlinks that are created.
7272
pub symlinks: Option<Vec<PathBuf>>,
73+
/// An error message if the environment is known to be in a bad state.
74+
/// For example, when the Python executable is a broken symlink.
75+
/// If None, no known issues have been detected (but this doesn't guarantee
76+
/// the environment is fully functional - we don't spawn Python to verify).
77+
pub error: Option<String>,
7378
}
7479

7580
impl Ord for PythonEnvironment {
@@ -176,6 +181,9 @@ impl std::fmt::Display for PythonEnvironment {
176181
}
177182
}
178183
}
184+
if let Some(error) = &self.error {
185+
writeln!(f, " Error : {error}").unwrap_or_default();
186+
}
179187
Ok(())
180188
}
181189
}
@@ -194,6 +202,7 @@ pub struct PythonEnvironmentBuilder {
194202
project: Option<PathBuf>,
195203
arch: Option<Architecture>,
196204
symlinks: Option<Vec<PathBuf>>,
205+
error: Option<String>,
197206
}
198207

199208
impl PythonEnvironmentBuilder {
@@ -209,6 +218,7 @@ impl PythonEnvironmentBuilder {
209218
project: None,
210219
arch: None,
211220
symlinks: None,
221+
error: None,
212222
}
213223
}
214224
pub fn from_environment(env: PythonEnvironment) -> Self {
@@ -223,6 +233,7 @@ impl PythonEnvironmentBuilder {
223233
project: env.project,
224234
arch: env.arch,
225235
symlinks: env.symlinks,
236+
error: env.error,
226237
}
227238
}
228239

@@ -285,6 +296,11 @@ impl PythonEnvironmentBuilder {
285296
self
286297
}
287298

299+
pub fn error(mut self, error: Option<String>) -> Self {
300+
self.error = error;
301+
self
302+
}
303+
288304
fn update_symlinks_and_exe(&mut self, symlinks: Option<Vec<PathBuf>>) {
289305
let mut all = self.symlinks.clone().unwrap_or_default();
290306
if let Some(ref exe) = self.executable {
@@ -340,6 +356,7 @@ impl PythonEnvironmentBuilder {
340356
project: self.project,
341357
arch: self.arch,
342358
symlinks,
359+
error: self.error,
343360
}
344361
}
345362
}

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

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,31 @@ lazy_static! {
1717
Regex::new(r"python(\d+\.?)*$").expect("error parsing Unix executable regex");
1818
}
1919

20+
/// Checks if a path is a broken symlink (symlink that points to a non-existent target).
21+
/// Returns true if the path is a symlink and its target does not exist.
22+
pub fn is_broken_symlink(path: &Path) -> bool {
23+
// First check if it's a symlink using symlink_metadata (doesn't follow symlinks)
24+
if let Ok(metadata) = fs::symlink_metadata(path) {
25+
if metadata.file_type().is_symlink() {
26+
// Now check if the target exists using regular metadata (follows symlinks)
27+
// If this fails or returns false for exists(), then it's broken
28+
return !path.exists();
29+
}
30+
}
31+
false
32+
}
33+
34+
/// Result of looking for an executable in an environment path.
35+
#[derive(Debug, Clone)]
36+
pub enum ExecutableResult {
37+
/// A valid executable was found
38+
Found(PathBuf),
39+
/// An executable path exists but is broken (e.g., broken symlink)
40+
Broken(PathBuf),
41+
/// No executable was found
42+
NotFound,
43+
}
44+
2045
#[cfg(windows)]
2146
pub fn find_executable(env_path: &Path) -> Option<PathBuf> {
2247
[
@@ -43,6 +68,56 @@ pub fn find_executable(env_path: &Path) -> Option<PathBuf> {
4368
.find(|path| path.is_file())
4469
}
4570

71+
/// Finds an executable in the environment path, including broken symlinks.
72+
/// This is useful for detecting virtual environments that have broken Python executables.
73+
#[cfg(windows)]
74+
pub fn find_executable_or_broken(env_path: &Path) -> ExecutableResult {
75+
let candidates = [
76+
env_path.join("Scripts").join("python.exe"),
77+
env_path.join("Scripts").join("python3.exe"),
78+
env_path.join("bin").join("python.exe"),
79+
env_path.join("bin").join("python3.exe"),
80+
env_path.join("python.exe"),
81+
env_path.join("python3.exe"),
82+
];
83+
84+
// First try to find a valid executable
85+
if let Some(path) = candidates.iter().find(|path| path.is_file()) {
86+
return ExecutableResult::Found(path.clone());
87+
}
88+
89+
// Then check for broken symlinks
90+
if let Some(path) = candidates.iter().find(|path| is_broken_symlink(path)) {
91+
return ExecutableResult::Broken(path.clone());
92+
}
93+
94+
ExecutableResult::NotFound
95+
}
96+
97+
/// Finds an executable in the environment path, including broken symlinks.
98+
/// This is useful for detecting virtual environments that have broken Python executables.
99+
#[cfg(unix)]
100+
pub fn find_executable_or_broken(env_path: &Path) -> ExecutableResult {
101+
let candidates = [
102+
env_path.join("bin").join("python"),
103+
env_path.join("bin").join("python3"),
104+
env_path.join("python"),
105+
env_path.join("python3"),
106+
];
107+
108+
// First try to find a valid executable
109+
if let Some(path) = candidates.iter().find(|path| path.is_file()) {
110+
return ExecutableResult::Found(path.clone());
111+
}
112+
113+
// Then check for broken symlinks
114+
if let Some(path) = candidates.iter().find(|path| is_broken_symlink(path)) {
115+
return ExecutableResult::Broken(path.clone());
116+
}
117+
118+
ExecutableResult::NotFound
119+
}
120+
46121
pub fn find_executables<T: AsRef<Path>>(env_path: T) -> Vec<PathBuf> {
47122
let mut env_path = env_path.as_ref().to_path_buf();
48123
// Never find exes in pyenv shims folder, they are not valid exes.

crates/pet-venv/src/lib.rs

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use pet_core::{
1010
reporter::Reporter,
1111
Locator, LocatorKind,
1212
};
13-
use pet_python_utils::executable::find_executables;
13+
use pet_python_utils::executable::{find_executable_or_broken, find_executables, ExecutableResult};
1414
use pet_python_utils::version;
1515

1616
fn is_venv_internal(env: &PythonEnv) -> Option<bool> {
@@ -26,6 +26,56 @@ pub fn is_venv(env: &PythonEnv) -> bool {
2626
pub fn is_venv_dir(path: &Path) -> bool {
2727
PyVenvCfg::find(path).is_some()
2828
}
29+
30+
/// Tries to create a PythonEnvironment from a directory that might be a venv.
31+
/// This function can detect broken environments (e.g., with broken symlinks)
32+
/// and will return them with an error field set.
33+
pub fn try_environment_from_venv_dir(path: &Path) -> Option<PythonEnvironment> {
34+
// Check if this is a venv directory
35+
let cfg = PyVenvCfg::find(path)?;
36+
37+
let prefix = path.to_path_buf();
38+
let version = version::from_creator_for_virtual_env(&prefix).or(Some(cfg.version.clone()));
39+
let name = cfg.prompt;
40+
41+
match find_executable_or_broken(path) {
42+
ExecutableResult::Found(executable) => {
43+
let symlinks = find_executables(&prefix);
44+
Some(
45+
PythonEnvironmentBuilder::new(Some(PythonEnvironmentKind::Venv))
46+
.name(name)
47+
.executable(Some(executable))
48+
.version(version)
49+
.prefix(Some(prefix))
50+
.symlinks(Some(symlinks))
51+
.build(),
52+
)
53+
}
54+
ExecutableResult::Broken(executable) => Some(
55+
PythonEnvironmentBuilder::new(Some(PythonEnvironmentKind::Venv))
56+
.name(name)
57+
.executable(Some(executable))
58+
.version(version)
59+
.prefix(Some(prefix))
60+
.error(Some(
61+
"Python executable is a broken symlink".to_string(),
62+
))
63+
.build(),
64+
),
65+
ExecutableResult::NotFound => {
66+
// pyvenv.cfg exists but no Python executable found at all
67+
Some(
68+
PythonEnvironmentBuilder::new(Some(PythonEnvironmentKind::Venv))
69+
.name(name)
70+
.version(version)
71+
.prefix(Some(prefix))
72+
.error(Some("Python executable not found".to_string()))
73+
.build(),
74+
)
75+
}
76+
}
77+
}
78+
2979
pub struct Venv {}
3080

3181
impl Venv {

crates/pet/src/find.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use pet_pixi::is_pixi_env;
1414
use pet_python_utils::executable::{
1515
find_executable, find_executables, should_search_for_environments_in_path,
1616
};
17+
use pet_venv::try_environment_from_venv_dir;
1718
use pet_virtualenv::is_virtualenv_dir;
1819
use serde::{Deserialize, Serialize};
1920
use std::collections::BTreeMap;
@@ -374,6 +375,13 @@ fn find_python_environments_in_paths_with_locators(
374375
if let Some(executable) = find_executable(path) {
375376
vec![executable]
376377
} else {
378+
// No valid executable found. Check if this is a broken venv.
379+
// If so, report it with an error instead of silently skipping.
380+
if let Some(broken_env) = try_environment_from_venv_dir(path) {
381+
if broken_env.error.is_some() {
382+
reporter.report_environment(&broken_env);
383+
}
384+
}
377385
vec![]
378386
}
379387
} else {

docs/JSONRPC.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,13 @@ interface Environment {
242242
* Thats because there could be multiple conda installations on the system, hence we try not to make any assumptions.
243243
*/
244244
manager?: Manager;
245+
/**
246+
* An error message if the environment is known to be in a bad state.
247+
* For example: "Python executable is a broken symlink"
248+
* If undefined, no known issues have been detected (but this doesn't guarantee
249+
* the environment is fully functional - we don't spawn Python to verify).
250+
*/
251+
error?: string;
245252
}
246253

247254
interface Manager {

0 commit comments

Comments
 (0)