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
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions crates/pet-pipenv/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ pet-core = { path = "../pet-core" }
pet-fs = { path = "../pet-fs" }
pet-virtualenv = { path = "../pet-virtualenv" }
pet-python-utils = { path = "../pet-python-utils" }
lazy_static = "1.4.0"
log = "0.4.21"
regex = "1.10.4"
128 changes: 121 additions & 7 deletions crates/pet-pipenv/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT License.

use env_variables::EnvVariables;
use lazy_static::lazy_static;
use log::trace;
use manager::PipenvManager;
use pet_core::env::PythonEnv;
Expand All @@ -15,13 +16,24 @@ use pet_core::{
use pet_fs::path::norm_case;
use pet_python_utils::executable::find_executables;
use pet_python_utils::version;
use regex::Regex;
use std::path::Path;
use std::sync::{Arc, RwLock};
use std::{fs, path::PathBuf};

mod env_variables;
pub mod manager;

lazy_static! {
/// Regex pattern for pipenv environment directory names.
/// Pipenv uses the naming convention: `{sanitized-project-name}-{8-char-hash}`
/// The hash is 8 characters of URL-safe base64 encoding of SHA256.
/// Pattern: one or more name segments (letters, digits, underscores) separated by hyphens,
/// followed by a hyphen and exactly 8 alphanumeric characters (including _ and -).
static ref PIPENV_ENV_NAME_PATTERN: Regex = Regex::new(r"^.+-[A-Za-z0-9_-]{8}$")
.expect("Error creating pipenv environment name pattern regex");
}

/// 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.
/// See: https://github.com/pypa/pipenv/blob/main/pipenv/utils/shell.py#L184
Expand Down Expand Up @@ -108,7 +120,7 @@ fn is_in_pipenv_centralized_dir(env: &PythonEnv, env_vars: &EnvVariables) -> boo
for pipenv_dir in &pipenv_dirs {
if let Some(parent) = prefix.parent() {
if norm_case(parent) == *pipenv_dir {
// Check if there's a .project file (pipenv always creates this for centralized envs)
// Primary check: .project file (pipenv always creates this for centralized envs)
let project_file = prefix.join(".project");
if project_file.exists() {
trace!(
Expand All @@ -117,12 +129,27 @@ fn is_in_pipenv_centralized_dir(env: &PythonEnv, env_vars: &EnvVariables) -> boo
pipenv_dir
);
return true;
} else {
trace!(
"Pipenv: Env {:?} is in pipenv dir {:?} but missing .project file",
prefix,
pipenv_dir
);
}

// Fallback: Check if directory name matches pipenv naming pattern
// Pattern: {sanitized-project-name}-{8-char-hash}
// This handles edge cases where .project was deleted, corrupted,
// or environments from older pipenv versions.
if let Some(dir_name) = prefix.file_name().and_then(|n| n.to_str()) {
if PIPENV_ENV_NAME_PATTERN.is_match(dir_name) {
trace!(
"Pipenv: Detected centralized pipenv env at {:?} (in {:?}, matched naming pattern, no .project file)",
prefix,
pipenv_dir
);
return true;
} else {
trace!(
"Pipenv: Env {:?} is in pipenv dir {:?} but missing .project file and name doesn't match pattern",
prefix,
pipenv_dir
);
}
}
}
}
Expand Down Expand Up @@ -678,4 +705,91 @@ mod tests {
// Cleanup
std::fs::remove_dir_all(&temp_home).ok();
}

#[test]
fn detect_pipenv_centralized_env_without_project_file_via_naming_pattern() {
// Test fallback detection when .project file is missing but directory name matches
// pipenv's naming pattern: {project-name}-{8-char-hash}
let temp_home = unique_temp_dir();
let virtualenvs_dir = temp_home.join(".local").join("share").join("virtualenvs");
// Use a name that matches pipenv pattern: name + hyphen + 8 alphanumeric chars
let venv_dir = virtualenvs_dir.join("myproject-AbC12xYz");
let bin_dir = if cfg!(windows) {
venv_dir.join("Scripts")
} else {
venv_dir.join("bin")
};
let python_exe = if cfg!(windows) {
bin_dir.join("python.exe")
} else {
bin_dir.join("python")
};

// Create the venv WITHOUT a .project file (simulating corrupted/deleted .project)
std::fs::create_dir_all(&bin_dir).unwrap();
std::fs::write(&python_exe, b"").unwrap();
std::fs::write(venv_dir.join("pyvenv.cfg"), b"version = 3.13.0\n").unwrap();
// Explicitly NOT creating .project file

// Construct PythonEnv
let env = PythonEnv {
executable: norm_case(python_exe.clone()),
prefix: Some(norm_case(venv_dir.clone())),
version: None,
symlinks: None,
};

let env_vars = EnvVariables {
pipenv_max_depth: 3,
pipenv_pipfile: "Pipfile".to_string(),
home: Some(temp_home.clone()),
xdg_data_home: None,
workon_home: None,
path: None,
};

// Should be detected via naming pattern fallback
assert!(
is_in_pipenv_centralized_dir(&env, &env_vars),
"Expected env to be detected in centralized dir via naming pattern"
);
assert!(
is_pipenv(&env, &env_vars),
"Expected env to be identified as pipenv via naming pattern"
);

// Locator should return the environment
let locator = PipEnv {
env_vars,
pipenv_executable: Arc::new(RwLock::new(None)),
};
let result = locator
.try_from(&env)
.expect("expected locator to return environment");
assert_eq!(result.kind, Some(PythonEnvironmentKind::Pipenv));
// Project should be None since there's no .project file and no Pipfile nearby
assert_eq!(result.project, None);

// Cleanup
std::fs::remove_dir_all(&temp_home).ok();
}

#[test]
fn test_pipenv_naming_pattern_regex() {
// Test that our regex correctly matches pipenv naming patterns
// Valid patterns: {name}-{8-char-hash}
assert!(PIPENV_ENV_NAME_PATTERN.is_match("myproject-AbC12xYz"));
assert!(PIPENV_ENV_NAME_PATTERN.is_match("my-project-AbC12xYz"));
assert!(PIPENV_ENV_NAME_PATTERN.is_match("my_project-AbC12xYz"));
assert!(PIPENV_ENV_NAME_PATTERN.is_match("project123-12345678"));
assert!(PIPENV_ENV_NAME_PATTERN.is_match("a-b-c-d-12345678"));
// URL-safe base64 can include _ and -
assert!(PIPENV_ENV_NAME_PATTERN.is_match("myproject-AbC_2-Yz"));

// Invalid patterns (should NOT match)
assert!(!PIPENV_ENV_NAME_PATTERN.is_match("myproject")); // no hash
assert!(!PIPENV_ENV_NAME_PATTERN.is_match("myproject-abc")); // hash too short (3 chars)
assert!(!PIPENV_ENV_NAME_PATTERN.is_match("myproject-abcdefg")); // hash too short (7 chars)
assert!(!PIPENV_ENV_NAME_PATTERN.is_match("-AbC12xYz")); // no project name
}
}
Loading