From 11e0d079c37876ce6a75122a52f3afa53cc71696 Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Tue, 3 Feb 2026 14:54:59 -0800 Subject: [PATCH 1/4] feat: Add Pipenv support with configuration options and environment management --- crates/pet-core/src/lib.rs | 1 + crates/pet-core/src/manager.rs | 1 + crates/pet-pipenv/src/env_variables.rs | 7 +- crates/pet-pipenv/src/lib.rs | 140 ++++++++++++++++++++++++- crates/pet-pipenv/src/manager.rs | 101 ++++++++++++++++++ crates/pet/src/jsonrpc.rs | 2 + docs/JSONRPC.md | 10 +- 7 files changed, 254 insertions(+), 8 deletions(-) create mode 100644 crates/pet-pipenv/src/manager.rs diff --git a/crates/pet-core/src/lib.rs b/crates/pet-core/src/lib.rs index c6d3dfa1..f4238c90 100644 --- a/crates/pet-core/src/lib.rs +++ b/crates/pet-core/src/lib.rs @@ -30,6 +30,7 @@ pub struct Configuration { pub workspace_directories: Option>, pub executables: Option>, pub conda_executable: Option, + pub pipenv_executable: Option, pub poetry_executable: Option, /// Custom locations where environments can be found. /// These are different from search_paths, as these are specific directories where environments are expected. diff --git a/crates/pet-core/src/manager.rs b/crates/pet-core/src/manager.rs index 7acd294a..d77cfe9b 100644 --- a/crates/pet-core/src/manager.rs +++ b/crates/pet-core/src/manager.rs @@ -7,6 +7,7 @@ use std::path::PathBuf; #[derive(Serialize, Deserialize, Copy, Clone, PartialEq, Eq, Debug, Hash)] pub enum EnvManagerType { Conda, + Pipenv, Poetry, Pyenv, } diff --git a/crates/pet-pipenv/src/env_variables.rs b/crates/pet-pipenv/src/env_variables.rs index b6879692..e6a9e8d9 100644 --- a/crates/pet-pipenv/src/env_variables.rs +++ b/crates/pet-pipenv/src/env_variables.rs @@ -11,9 +11,13 @@ pub struct EnvVariables { #[allow(dead_code)] pub pipenv_max_depth: u16, pub pipenv_pipfile: String, + /// User's home directory pub home: Option, - pub xdg_data_home: Option, + /// Maps to env var `WORKON_HOME` - custom directory for virtual environments pub workon_home: Option, + pub xdg_data_home: Option, + /// Maps to env var `PATH` + pub path: Option, } impl EnvVariables { @@ -31,6 +35,7 @@ impl EnvVariables { workon_home: env .get_env_var("WORKON_HOME".to_string()) .map(PathBuf::from), + path: env.get_env_var("PATH".to_string()), } } } diff --git a/crates/pet-pipenv/src/lib.rs b/crates/pet-pipenv/src/lib.rs index e11333f4..848bd735 100644 --- a/crates/pet-pipenv/src/lib.rs +++ b/crates/pet-pipenv/src/lib.rs @@ -3,21 +3,24 @@ use env_variables::EnvVariables; use log::trace; +use manager::PipenvManager; use pet_core::env::PythonEnv; use pet_core::os_environment::Environment; use pet_core::LocatorKind; use pet_core::{ python_environment::{PythonEnvironment, PythonEnvironmentBuilder, PythonEnvironmentKind}, reporter::Reporter, - Locator, + Configuration, Locator, }; use pet_fs::path::norm_case; use pet_python_utils::executable::find_executables; use pet_python_utils::version; use std::path::Path; +use std::sync::{Arc, RwLock}; use std::{fs, path::PathBuf}; mod env_variables; +pub mod manager; /// Returns the list of directories where pipenv stores centralized virtual environments. /// These are the known locations where pipenv creates virtualenvs when not using in-project mode. @@ -272,21 +275,129 @@ fn is_pipenv(env: &PythonEnv, env_vars: &EnvVariables) -> bool { false } +/// Get the default virtualenvs directory for pipenv +/// - If WORKON_HOME is set, use that +/// - Linux/macOS: ~/.local/share/virtualenvs/ +/// - Windows: %USERPROFILE%\.virtualenvs\ +fn get_virtualenvs_dir(env_vars: &EnvVariables) -> Option { + // First check WORKON_HOME environment variable + if let Some(workon_home) = &env_vars.workon_home { + if workon_home.is_dir() { + return Some(workon_home.clone()); + } + } + + // Fall back to default locations + if let Some(home) = &env_vars.home { + if std::env::consts::OS == "windows" { + let dir = home.join(".virtualenvs"); + if dir.is_dir() { + return Some(dir); + } + } else { + let dir = home.join(".local").join("share").join("virtualenvs"); + if dir.is_dir() { + return Some(dir); + } + } + } + + None +} + +/// Discover pipenv environments from the virtualenvs directory +fn list_environments(env_vars: &EnvVariables) -> Vec { + let mut environments = vec![]; + + if let Some(virtualenvs_dir) = get_virtualenvs_dir(env_vars) { + trace!("Searching for pipenv environments in {:?}", virtualenvs_dir); + + if let Ok(entries) = fs::read_dir(&virtualenvs_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + // Check if this directory is a valid virtualenv with a .project file + let project_file = path.join(".project"); + if !project_file.exists() { + continue; + } + + // Read the project path from .project file + if let Ok(project_contents) = fs::read_to_string(&project_file) { + let project_path = PathBuf::from(project_contents.trim()); + let project_path = norm_case(project_path); + + // Check if the project has a Pipfile + if !project_path.join(&env_vars.pipenv_pipfile).exists() { + continue; + } + + // Find the Python executable in the virtualenv + let bin_dir = if std::env::consts::OS == "windows" { + path.join("Scripts") + } else { + path.join("bin") + }; + + let python_exe = if std::env::consts::OS == "windows" { + bin_dir.join("python.exe") + } else { + bin_dir.join("python") + }; + + if python_exe.is_file() { + let symlinks = find_executables(&bin_dir); + let version = version::from_creator_for_virtual_env(&path); + + let env = PythonEnvironmentBuilder::new(Some( + PythonEnvironmentKind::Pipenv, + )) + .executable(Some(norm_case(python_exe))) + .version(version) + .prefix(Some(norm_case(path.clone()))) + .project(Some(project_path)) + .symlinks(Some(symlinks)) + .build(); + + trace!("Found pipenv environment: {:?}", env); + environments.push(env); + } + } + } + } + } + + environments +} + pub struct PipEnv { env_vars: EnvVariables, + pipenv_executable: Arc>>, } impl PipEnv { pub fn from(environment: &dyn Environment) -> PipEnv { PipEnv { env_vars: EnvVariables::from(environment), + pipenv_executable: Arc::new(RwLock::new(None)), } } } + impl Locator for PipEnv { fn get_kind(&self) -> LocatorKind { LocatorKind::PipEnv } + + fn configure(&self, config: &Configuration) { + if let Some(exe) = &config.pipenv_executable { + self.pipenv_executable.write().unwrap().replace(exe.clone()); + } + } + fn supported_categories(&self) -> Vec { vec![PythonEnvironmentKind::Pipenv] } @@ -334,8 +445,19 @@ impl Locator for PipEnv { ) } - fn find(&self, _reporter: &dyn Reporter) { - // + fn find(&self, reporter: &dyn Reporter) { + // First, find and report the pipenv manager + let pipenv_exe = self.pipenv_executable.read().unwrap().clone(); + if let Some(manager) = PipenvManager::find(pipenv_exe, &self.env_vars) { + trace!("Found pipenv manager: {:?}", manager); + reporter.report_manager(&manager.to_manager()); + } + + // Then discover and report pipenv environments + let environments = list_environments(&self.env_vars); + for env in environments { + reporter.report_environment(&env); + } } } @@ -361,6 +483,7 @@ mod tests { home, xdg_data_home: None, workon_home: None, + path: None, } } @@ -402,6 +525,7 @@ mod tests { // Validate locator populates project let locator = PipEnv { env_vars: create_test_env_vars(None), + pipenv_executable: Arc::new(RwLock::new(None)), }; let result = locator .try_from(&env) @@ -475,7 +599,10 @@ mod tests { ); // Validate locator returns the environment - let locator = PipEnv { env_vars }; + let locator = PipEnv { + env_vars, + pipenv_executable: Arc::new(RwLock::new(None)), + }; let result = locator .try_from(&env) .expect("expected locator to return environment"); @@ -538,7 +665,10 @@ mod tests { ); // Locator should return the environment, but project will point to non-existent path - let locator = PipEnv { env_vars }; + let locator = PipEnv { + env_vars, + pipenv_executable: Arc::new(RwLock::new(None)), + }; let result = locator .try_from(&env) .expect("expected locator to return environment"); diff --git a/crates/pet-pipenv/src/manager.rs b/crates/pet-pipenv/src/manager.rs new file mode 100644 index 00000000..f7692362 --- /dev/null +++ b/crates/pet-pipenv/src/manager.rs @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use log::trace; +use pet_core::manager::{EnvManager, EnvManagerType}; +use std::{env, path::PathBuf}; + +use crate::env_variables::EnvVariables; + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct PipenvManager { + pub executable: PathBuf, +} + +impl PipenvManager { + pub fn find(executable: Option, env_variables: &EnvVariables) -> Option { + // If an explicit executable path is provided, check if it exists + if let Some(executable) = executable { + if executable.is_file() { + return Some(PipenvManager { executable }); + } + } + + // Search in common installation locations + if let Some(home) = &env_variables.home { + let mut search_paths = vec![ + // pip install --user pipenv on Linux/macOS + home.join(".local").join("bin").join("pipenv"), + // pipx install pipenv + home.join(".local") + .join("pipx") + .join("venvs") + .join("pipenv") + .join("bin") + .join("pipenv"), + ]; + + if std::env::consts::OS == "windows" { + // pip install --user pipenv on Windows + search_paths.push( + home.join("AppData") + .join("Roaming") + .join("Python") + .join("Scripts") + .join("pipenv.exe"), + ); + // Another common Windows location + search_paths.push( + home.join("AppData") + .join("Local") + .join("Programs") + .join("Python") + .join("Scripts") + .join("pipenv.exe"), + ); + // pipx on Windows + search_paths.push( + home.join(".local") + .join("pipx") + .join("venvs") + .join("pipenv") + .join("Scripts") + .join("pipenv.exe"), + ); + } + + for executable in search_paths { + if executable.is_file() { + return Some(PipenvManager { executable }); + } + } + + // Look for pipenv in current PATH + if let Some(env_path) = &env_variables.path { + for each in env::split_paths(env_path) { + let executable = each.join("pipenv"); + if executable.is_file() { + return Some(PipenvManager { executable }); + } + if std::env::consts::OS == "windows" { + let executable = each.join("pipenv.exe"); + if executable.is_file() { + return Some(PipenvManager { executable }); + } + } + } + } + } + + trace!("Pipenv exe not found"); + None + } + + pub fn to_manager(&self) -> EnvManager { + EnvManager { + executable: self.executable.clone(), + version: None, + tool: EnvManagerType::Pipenv, + } + } +} diff --git a/crates/pet/src/jsonrpc.rs b/crates/pet/src/jsonrpc.rs index 46b41877..3762d090 100644 --- a/crates/pet/src/jsonrpc.rs +++ b/crates/pet/src/jsonrpc.rs @@ -100,6 +100,7 @@ pub struct ConfigureOptions { /// Glob patterns are supported (e.g., "/home/user/projects/*"). pub workspace_directories: Option>, pub conda_executable: Option, + pub pipenv_executable: Option, pub poetry_executable: Option, /// Custom locations where environments can be found. Generally global locations where virtualenvs & the like can be found. /// Workspace directories should not be included into this list. @@ -131,6 +132,7 @@ pub fn handle_configure(context: Arc, id: u32, params: Value) { .filter(|p| p.is_dir()) .collect() }); + cfg.pipenv_executable = configure_options.pipenv_executable; cfg.poetry_executable = configure_options.poetry_executable; // We will not support changing the cache directories once set. // No point, supporting such a use case. diff --git a/docs/JSONRPC.md b/docs/JSONRPC.md index dec64c64..85b234fe 100644 --- a/docs/JSONRPC.md +++ b/docs/JSONRPC.md @@ -57,7 +57,13 @@ interface ConfigureParams { */ condaExecutable?: string; /** - * This is the path to the conda executable. + * This is the path to the pipenv executable. + * + * Useful for VS Code so users can configure where they have installed Pipenv. + */ + pipenvExecutable?: string; + /** + * This is the path to the poetry executable. * * Useful for VS Code so users can configure where they have installed Poetry. */ @@ -253,7 +259,7 @@ interface Manager { /** * The type of the Manager. */ - tool: "Conda" | "Poetry" | "Pyenv"; + tool: "Conda" | "Pipenv" | "Poetry" | "Pyenv"; /** * The version of the manager/tool. * In the case of conda, this is the version of conda. From bf8d897e423f5a0b2a4fb0adf6a9ccd6d981c37f Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Tue, 3 Feb 2026 16:06:08 -0800 Subject: [PATCH 2/4] Fix clippy: make MAIN_SEPARATOR import conditional for unix --- crates/pet-fs/src/path.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/pet-fs/src/path.rs b/crates/pet-fs/src/path.rs index d64621f3..c4ead747 100644 --- a/crates/pet-fs/src/path.rs +++ b/crates/pet-fs/src/path.rs @@ -3,9 +3,12 @@ use std::{ env, - path::{Path, PathBuf, MAIN_SEPARATOR}, + path::{Path, PathBuf}, }; +#[cfg(unix)] +use std::path::MAIN_SEPARATOR; + /// Strips trailing path separators from a path, preserving root paths. /// /// This function removes trailing `/` or `\` from paths while ensuring that root paths From 0df664d17f338079333d0151b50fafd8413161c0 Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Tue, 3 Feb 2026 16:12:21 -0800 Subject: [PATCH 3/4] Apply cargo fmt formatting --- crates/pet-pipenv/src/lib.rs | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/crates/pet-pipenv/src/lib.rs b/crates/pet-pipenv/src/lib.rs index 848bd735..0438b10a 100644 --- a/crates/pet-pipenv/src/lib.rs +++ b/crates/pet-pipenv/src/lib.rs @@ -352,15 +352,14 @@ fn list_environments(env_vars: &EnvVariables) -> Vec { let symlinks = find_executables(&bin_dir); let version = version::from_creator_for_virtual_env(&path); - let env = PythonEnvironmentBuilder::new(Some( - PythonEnvironmentKind::Pipenv, - )) - .executable(Some(norm_case(python_exe))) - .version(version) - .prefix(Some(norm_case(path.clone()))) - .project(Some(project_path)) - .symlinks(Some(symlinks)) - .build(); + let env = + PythonEnvironmentBuilder::new(Some(PythonEnvironmentKind::Pipenv)) + .executable(Some(norm_case(python_exe))) + .version(version) + .prefix(Some(norm_case(path.clone()))) + .project(Some(project_path)) + .symlinks(Some(symlinks)) + .build(); trace!("Found pipenv environment: {:?}", env); environments.push(env); From 71c906a8684f5b2b3f4f067109948419ec56958c Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Wed, 4 Feb 2026 09:38:48 -0800 Subject: [PATCH 4/4] fix(tests): Initialize path in Pipenv test cases --- crates/pet-pipenv/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/pet-pipenv/src/lib.rs b/crates/pet-pipenv/src/lib.rs index 0438b10a..07a08586 100644 --- a/crates/pet-pipenv/src/lib.rs +++ b/crates/pet-pipenv/src/lib.rs @@ -583,6 +583,7 @@ mod tests { home: Some(temp_home.clone()), xdg_data_home: None, workon_home: None, + path: None, }; // Validate is_in_pipenv_centralized_dir detects it @@ -651,6 +652,7 @@ mod tests { home: Some(temp_home.clone()), xdg_data_home: None, workon_home: None, + path: None, }; // Should still be detected as pipenv (centralized directory + .project file)