Skip to content
Draft
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
207 changes: 207 additions & 0 deletions crates/pet-fs/src/path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,48 @@ fn get_user_home() -> Option<PathBuf> {
}
}

/// 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 `<dir>/.venv` is a directory, return that path.
/// 2. If `<dir>/.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 `<dir>` using `dir.join(path)`.
/// The returned path is not further canonicalized, so it may remain relative if `<dir>`
/// is relative and may preserve `.` or `..` components.
/// - If the resolved path does not exist or is not a directory, return `None`.
/// 3. If `<dir>/.venv` does not exist, return `None`.
///
/// See: <https://www.python.org/dev/peps/pep-0832/#specification>
pub fn resolve_dot_venv(dir: &Path) -> Option<PathBuf> {
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)
};
Comment thread
edvilme marked this conversation as resolved.
if resolved_path.is_dir() {
Some(resolved_path)
} else {
None
}
} else {
None
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -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]
Comment thread
edvilme marked this conversation as resolved.
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);
}
}
8 changes: 5 additions & 3 deletions crates/pet-poetry/src/environment_locations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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)
Expand Down
34 changes: 20 additions & 14 deletions crates/pet-uv/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -422,7 +423,7 @@ fn find_workspace_for_project(project_path: &Path) -> Option<PythonEnvironment>
/// 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<PythonEnvironment> {
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!(
Expand Down Expand Up @@ -465,25 +466,30 @@ fn list_envs_in_directory(path: &Path) -> Vec<PythonEnvironment> {
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)
.and_then(|uv| uv.workspace)
.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))
Expand All @@ -501,7 +507,7 @@ fn list_envs_in_directory(path: &Path) -> Vec<PythonEnvironment> {
}
// 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))
Expand Down
7 changes: 5 additions & 2 deletions crates/pet/src/find.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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")) {
Expand Down
Loading