diff --git a/crates/pet-fs/src/path.rs b/crates/pet-fs/src/path.rs index 8019ed0d..6411ebe6 100644 --- a/crates/pet-fs/src/path.rs +++ b/crates/pet-fs/src/path.rs @@ -334,6 +334,48 @@ fn get_user_home() -> Option { } } +/// Resolves the `.venv` entry in a directory to the virtual environment path. `.venv` may be either +/// - A **directory**: The virtual environment itself (traditional convention) +/// - A **file**: A text file containing the path (relative or absolute) to the virtual environment +/// located somewhere else in disk (PEP 832 convention) +/// +/// # Resolution order +/// 1. If `/.venv` is a directory, return that path. +/// 2. If `/.venv` is a file, read its contents, trim whitespaces, and resolve the path: +/// - If the path is absolute, return it. +/// - If the path is relative, resolve it against `` using `dir.join(path)`. +/// The returned path is not further canonicalized, so it may remain relative if `` +/// is relative and may preserve `.` or `..` components. +/// - If the resolved path does not exist or is not a directory, return `None`. +/// 3. If `/.venv` does not exist, return `None`. +/// +/// See: +pub fn resolve_dot_venv(dir: &Path) -> Option { + let dot_venv = dir.join(".venv"); + let meta = std::fs::metadata(&dot_venv).ok()?; + if meta.is_dir() { + Some(dot_venv) + } else if meta.is_file() { + let content = std::fs::read_to_string(&dot_venv).ok()?.trim().to_string(); + if content.is_empty() { + return None; + } + let path = PathBuf::from(content); + let resolved_path = if path.is_absolute() { + path + } else { + dir.join(path) + }; + if resolved_path.is_dir() { + Some(resolved_path) + } else { + None + } + } else { + None + } +} + #[cfg(test)] mod tests { use super::*; @@ -694,4 +736,169 @@ mod tests { let _ = std::fs::remove_file(&symlink_path); let _ = std::fs::remove_file(&target_file); } + + // ==================== resolve_dot_venv tests ==================== + + #[test] + fn test_resolve_dot_venv_no_dot_venv() { + // If .venv does not exist, return None + let temp_dir = std::env::temp_dir(); + let test_dir = temp_dir.join("pet_test_no_dot_venv"); + let _ = std::fs::remove_dir_all(&test_dir); + std::fs::create_dir_all(&test_dir).expect("Failed to create test dir"); + + assert_eq!(resolve_dot_venv(&test_dir), None); + + let _ = std::fs::remove_dir_all(&test_dir); + } + + #[test] + fn test_resolve_dot_venv_directory() { + // If .venv is a directory, return its path + let temp_dir = std::env::temp_dir(); + let test_dir = temp_dir.join("pet_test_dot_venv_dir"); + let dot_venv = test_dir.join(".venv"); + + let _ = std::fs::remove_dir_all(&test_dir); + std::fs::create_dir_all(&dot_venv).expect("Failed to create .venv dir"); + + let result = resolve_dot_venv(&test_dir); + assert_eq!(result, Some(dot_venv)); + + let _ = std::fs::remove_dir_all(&test_dir); + } + + #[test] + fn test_resolve_dot_venv_file_absolute_path() { + // If .venv is a file containing an absolute path to an existing directory, return it + let temp_dir = std::env::temp_dir(); + let test_dir = temp_dir.join("pet_test_dot_venv_file_abs"); + let target_venv = temp_dir.join("pet_test_dot_venv_target_abs"); + + let _ = std::fs::remove_dir_all(&test_dir); + let _ = std::fs::remove_dir_all(&target_venv); + std::fs::create_dir_all(&test_dir).expect("Failed to create test dir"); + std::fs::create_dir_all(&target_venv).expect("Failed to create target venv dir"); + + let dot_venv_file = test_dir.join(".venv"); + std::fs::write(&dot_venv_file, target_venv.to_string_lossy().as_ref()) + .expect("Failed to write .venv file"); + + let result = resolve_dot_venv(&test_dir); + assert_eq!(result, Some(target_venv.clone())); + + let _ = std::fs::remove_dir_all(&test_dir); + let _ = std::fs::remove_dir_all(&target_venv); + } + + #[test] + fn test_resolve_dot_venv_file_relative_path() { + // If .venv is a file containing a relative path, resolve it against the dir + let temp_dir = std::env::temp_dir(); + let test_dir = temp_dir.join("pet_test_dot_venv_file_rel"); + let target_venv = test_dir.join("my_venv"); + + let _ = std::fs::remove_dir_all(&test_dir); + std::fs::create_dir_all(&target_venv).expect("Failed to create target venv dir"); + + let dot_venv_file = test_dir.join(".venv"); + std::fs::write(&dot_venv_file, "my_venv").expect("Failed to write .venv file"); + + let result = resolve_dot_venv(&test_dir); + assert_eq!(result, Some(target_venv.clone())); + + let _ = std::fs::remove_dir_all(&test_dir); + } + + #[test] + fn test_resolve_dot_venv_file_nonexistent_target() { + // If .venv is a file pointing to a non-existent directory, return None + let temp_dir = std::env::temp_dir(); + let test_dir = temp_dir.join("pet_test_dot_venv_file_missing"); + + let _ = std::fs::remove_dir_all(&test_dir); + std::fs::create_dir_all(&test_dir).expect("Failed to create test dir"); + + let dot_venv_file = test_dir.join(".venv"); + std::fs::write(&dot_venv_file, "/this/path/does/not/exist") + .expect("Failed to write .venv file"); + + assert_eq!(resolve_dot_venv(&test_dir), None); + + let _ = std::fs::remove_dir_all(&test_dir); + } + + #[test] + fn test_resolve_dot_venv_file_whitespace_trimmed() { + // Whitespace around the path should be trimmed + let temp_dir = std::env::temp_dir(); + let test_dir = temp_dir.join("pet_test_dot_venv_whitespace"); + let target_venv = test_dir.join("ws_venv"); + + let _ = std::fs::remove_dir_all(&test_dir); + std::fs::create_dir_all(&target_venv).expect("Failed to create target venv dir"); + + let dot_venv_file = test_dir.join(".venv"); + std::fs::write(&dot_venv_file, " ws_venv \n").expect("Failed to write .venv file"); + + let result = resolve_dot_venv(&test_dir); + assert_eq!(result, Some(target_venv.clone())); + + let _ = std::fs::remove_dir_all(&test_dir); + } + + #[test] + fn test_resolve_dot_venv_file_whitespace_only_returns_none() { + // A whitespace-only .venv file should not resolve to the project directory + let temp_dir = std::env::temp_dir(); + let test_dir = temp_dir.join("pet_test_dot_venv_whitespace_only"); + + let _ = std::fs::remove_dir_all(&test_dir); + std::fs::create_dir_all(&test_dir).expect("Failed to create test dir"); + + let dot_venv_file = test_dir.join(".venv"); + std::fs::write(&dot_venv_file, " \n\t ").expect("Failed to write .venv file"); + + assert_eq!(resolve_dot_venv(&test_dir), None); + + let _ = std::fs::remove_dir_all(&test_dir); + } + + #[cfg(unix)] + #[test] + fn test_resolve_dot_venv_symlink_to_directory() { + // A .venv symlink to a directory should be treated like a directory + let temp_dir = std::env::temp_dir(); + let test_dir = temp_dir.join("pet_test_dot_venv_symlink_dir"); + let target_venv = test_dir.join("actual_venv"); + let dot_venv = test_dir.join(".venv"); + + let _ = std::fs::remove_dir_all(&test_dir); + std::fs::create_dir_all(&target_venv).expect("Failed to create target venv dir"); + std::os::unix::fs::symlink(&target_venv, &dot_venv) + .expect("Failed to create .venv symlink"); + + assert_eq!(resolve_dot_venv(&test_dir), Some(dot_venv.clone())); + + let _ = std::fs::remove_dir_all(&test_dir); + } + + #[test] + fn test_resolve_dot_venv_file_points_to_file_not_dir() { + // If .venv file points to a path that exists but is a file (not a dir), return None + let temp_dir = std::env::temp_dir(); + let test_dir = temp_dir.join("pet_test_dot_venv_target_is_file"); + let target_file = test_dir.join("not_a_dir"); + + let _ = std::fs::remove_dir_all(&test_dir); + std::fs::create_dir_all(&test_dir).expect("Failed to create test dir"); + std::fs::write(&target_file, "I am a file").expect("Failed to create target file"); + + let dot_venv_file = test_dir.join(".venv"); + std::fs::write(&dot_venv_file, "not_a_dir").expect("Failed to write .venv file"); + + assert_eq!(resolve_dot_venv(&test_dir), None); + + let _ = std::fs::remove_dir_all(&test_dir); + } } diff --git a/crates/pet-poetry/src/environment_locations.rs b/crates/pet-poetry/src/environment_locations.rs index 98603354..5d32962c 100644 --- a/crates/pet-poetry/src/environment_locations.rs +++ b/crates/pet-poetry/src/environment_locations.rs @@ -6,6 +6,7 @@ use lazy_static::lazy_static; use log::trace; use pet_core::python_environment::PythonEnvironment; use pet_fs::path::norm_case; +use pet_fs::path::resolve_dot_venv; use regex::Regex; use sha2::{Digest, Sha256}; use std::{ @@ -108,9 +109,10 @@ fn list_all_environments_from_project_config( // Order of preference is Project (local config) > EnvVariable > Global if should_use_local_venv_as_poetry_env(global, &local, env) { // If virtualenvs are in the project, then look for .venv - let venv = path.join(".venv"); - if venv.is_dir() { - envs.push(venv); + if let Some(venv) = resolve_dot_venv(path) { + if venv.is_dir() { + envs.push(venv); + } } } Some(envs) diff --git a/crates/pet-uv/src/lib.rs b/crates/pet-uv/src/lib.rs index c415fd39..7921140a 100644 --- a/crates/pet-uv/src/lib.rs +++ b/crates/pet-uv/src/lib.rs @@ -15,6 +15,7 @@ use pet_core::{ Configuration, Locator, LocatorKind, RefreshStatePersistence, }; use pet_fs::path::norm_case; +use pet_fs::path::resolve_dot_venv; use pet_python_utils::executable::{find_executable, find_executables}; use serde::Deserialize; @@ -422,7 +423,7 @@ fn find_workspace_for_project(project_path: &Path) -> Option /// Builds a `PythonEnvironment` for a uv workspace root if it has a `.venv` with a valid /// uv-managed pyvenv.cfg. fn build_workspace_env(workspace_root: &Path) -> Option { - let prefix = workspace_root.join(".venv"); + let prefix = resolve_dot_venv(workspace_root)?; let pyvenv_cfg = prefix.join("pyvenv.cfg"); if !pyvenv_cfg.exists() { trace!( @@ -465,17 +466,22 @@ fn list_envs_in_directory(path: &Path) -> Vec { let Some(pyproject) = pyproject else { return envs; }; - let pyvenv_cfg = path.join(".venv/pyvenv.cfg"); - let prefix = path.join(".venv"); - let unix_executable = prefix.join("bin/python"); - let windows_executable = prefix.join("Scripts/python.exe"); - let executable = if unix_executable.exists() { - Some(unix_executable) - } else if windows_executable.exists() { - Some(windows_executable) - } else { - None - }; + let prefix = resolve_dot_venv(path); + let uv_venv = prefix + .as_ref() + .map(|p| p.join("pyvenv.cfg")) + .and_then(|cfg| UvVenv::maybe_from_file(&cfg)); + let executable = prefix.as_ref().and_then(|p| { + let unix_executable = p.join("bin/python"); + let windows_executable = p.join("Scripts/python.exe"); + if unix_executable.exists() { + Some(unix_executable) + } else if windows_executable.exists() { + Some(windows_executable) + } else { + None + } + }); if pyproject .tool .and_then(|t| t.uv) @@ -483,7 +489,7 @@ fn list_envs_in_directory(path: &Path) -> Vec { .is_some() { trace!("Workspace found in {}", path.display()); - if let Some(uv_venv) = UvVenv::maybe_from_file(&pyvenv_cfg) { + if let (Some(uv_venv), Some(prefix)) = (uv_venv, prefix) { trace!("uv-managed venv found for workspace in {}", path.display()); let env = PythonEnvironmentBuilder::new(Some(PythonEnvironmentKind::UvWorkspace)) .name(Some(uv_venv.prompt)) @@ -501,7 +507,7 @@ fn list_envs_in_directory(path: &Path) -> Vec { } // prioritize the workspace over the project if it's the same venv } else if let Some(project) = pyproject.project { - if let Some(uv_venv) = UvVenv::maybe_from_file(&pyvenv_cfg) { + if let (Some(uv_venv), Some(prefix)) = (uv_venv, prefix) { trace!("uv-managed venv found for project in {}", path.display()); let env = PythonEnvironmentBuilder::new(Some(PythonEnvironmentKind::Uv)) .name(Some(uv_venv.prompt)) diff --git a/crates/pet/src/find.rs b/crates/pet/src/find.rs index 30d9a631..5ca8e669 100644 --- a/crates/pet/src/find.rs +++ b/crates/pet/src/find.rs @@ -9,6 +9,7 @@ use pet_core::python_environment::PythonEnvironmentKind; use pet_core::reporter::Reporter; use pet_core::{Configuration, Locator, LocatorKind}; use pet_env_var_path::get_search_paths_from_env_variables; +use pet_fs::path::resolve_dot_venv; use pet_global_virtualenvs::list_global_virtual_envs_paths; use pet_pixi::is_pixi_env; use pet_python_utils::executable::{ @@ -276,12 +277,14 @@ pub fn find_python_environments_in_workspace_folder_recursive( let mut paths_to_search_first = vec![ // Possible this is a virtual env workspace_folder.to_path_buf(), - // Optimize for finding these first. - workspace_folder.join(".venv"), workspace_folder.join(".conda"), workspace_folder.join(".virtualenv"), workspace_folder.join("venv"), ]; + // Optimize for finding .venv first (supports PEP 832 file-based .venv). + if let Some(dot_venv) = resolve_dot_venv(workspace_folder) { + paths_to_search_first.insert(1, dot_venv); + } // Add all subdirectories of .pixi/envs/** if let Ok(reader) = fs::read_dir(workspace_folder.join(".pixi").join("envs")) {