Skip to content

Commit 6e3266d

Browse files
authored
feat: add regex pattern for pipenv environment naming and enhance detection logic (#339)
Fixes #336
1 parent 20515e2 commit 6e3266d

File tree

3 files changed

+125
-7
lines changed

3 files changed

+125
-7
lines changed

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/pet-pipenv/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,6 @@ pet-core = { path = "../pet-core" }
1212
pet-fs = { path = "../pet-fs" }
1313
pet-virtualenv = { path = "../pet-virtualenv" }
1414
pet-python-utils = { path = "../pet-python-utils" }
15+
lazy_static = "1.4.0"
1516
log = "0.4.21"
17+
regex = "1.10.4"

crates/pet-pipenv/src/lib.rs

Lines changed: 121 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the MIT License.
33

44
use env_variables::EnvVariables;
5+
use lazy_static::lazy_static;
56
use log::trace;
67
use manager::PipenvManager;
78
use pet_core::env::PythonEnv;
@@ -15,13 +16,24 @@ use pet_core::{
1516
use pet_fs::path::norm_case;
1617
use pet_python_utils::executable::find_executables;
1718
use pet_python_utils::version;
19+
use regex::Regex;
1820
use std::path::Path;
1921
use std::sync::{Arc, RwLock};
2022
use std::{fs, path::PathBuf};
2123

2224
mod env_variables;
2325
pub mod manager;
2426

27+
lazy_static! {
28+
/// Regex pattern for pipenv environment directory names.
29+
/// Pipenv uses the naming convention: `{sanitized-project-name}-{8-char-hash}`
30+
/// The hash is 8 characters of URL-safe base64 encoding of SHA256.
31+
/// Pattern: one or more name segments (letters, digits, underscores) separated by hyphens,
32+
/// followed by a hyphen and exactly 8 alphanumeric characters (including _ and -).
33+
static ref PIPENV_ENV_NAME_PATTERN: Regex = Regex::new(r"^.+-[A-Za-z0-9_-]{8}$")
34+
.expect("Error creating pipenv environment name pattern regex");
35+
}
36+
2537
/// Returns the list of directories where pipenv stores centralized virtual environments.
2638
/// These are the known locations where pipenv creates virtualenvs when not using in-project mode.
2739
/// See: https://github.com/pypa/pipenv/blob/main/pipenv/utils/shell.py#L184
@@ -108,7 +120,7 @@ fn is_in_pipenv_centralized_dir(env: &PythonEnv, env_vars: &EnvVariables) -> boo
108120
for pipenv_dir in &pipenv_dirs {
109121
if let Some(parent) = prefix.parent() {
110122
if norm_case(parent) == *pipenv_dir {
111-
// Check if there's a .project file (pipenv always creates this for centralized envs)
123+
// Primary check: .project file (pipenv always creates this for centralized envs)
112124
let project_file = prefix.join(".project");
113125
if project_file.exists() {
114126
trace!(
@@ -117,12 +129,27 @@ fn is_in_pipenv_centralized_dir(env: &PythonEnv, env_vars: &EnvVariables) -> boo
117129
pipenv_dir
118130
);
119131
return true;
120-
} else {
121-
trace!(
122-
"Pipenv: Env {:?} is in pipenv dir {:?} but missing .project file",
123-
prefix,
124-
pipenv_dir
125-
);
132+
}
133+
134+
// Fallback: Check if directory name matches pipenv naming pattern
135+
// Pattern: {sanitized-project-name}-{8-char-hash}
136+
// This handles edge cases where .project was deleted, corrupted,
137+
// or environments from older pipenv versions.
138+
if let Some(dir_name) = prefix.file_name().and_then(|n| n.to_str()) {
139+
if PIPENV_ENV_NAME_PATTERN.is_match(dir_name) {
140+
trace!(
141+
"Pipenv: Detected centralized pipenv env at {:?} (in {:?}, matched naming pattern, no .project file)",
142+
prefix,
143+
pipenv_dir
144+
);
145+
return true;
146+
} else {
147+
trace!(
148+
"Pipenv: Env {:?} is in pipenv dir {:?} but missing .project file and name doesn't match pattern",
149+
prefix,
150+
pipenv_dir
151+
);
152+
}
126153
}
127154
}
128155
}
@@ -678,4 +705,91 @@ mod tests {
678705
// Cleanup
679706
std::fs::remove_dir_all(&temp_home).ok();
680707
}
708+
709+
#[test]
710+
fn detect_pipenv_centralized_env_without_project_file_via_naming_pattern() {
711+
// Test fallback detection when .project file is missing but directory name matches
712+
// pipenv's naming pattern: {project-name}-{8-char-hash}
713+
let temp_home = unique_temp_dir();
714+
let virtualenvs_dir = temp_home.join(".local").join("share").join("virtualenvs");
715+
// Use a name that matches pipenv pattern: name + hyphen + 8 alphanumeric chars
716+
let venv_dir = virtualenvs_dir.join("myproject-AbC12xYz");
717+
let bin_dir = if cfg!(windows) {
718+
venv_dir.join("Scripts")
719+
} else {
720+
venv_dir.join("bin")
721+
};
722+
let python_exe = if cfg!(windows) {
723+
bin_dir.join("python.exe")
724+
} else {
725+
bin_dir.join("python")
726+
};
727+
728+
// Create the venv WITHOUT a .project file (simulating corrupted/deleted .project)
729+
std::fs::create_dir_all(&bin_dir).unwrap();
730+
std::fs::write(&python_exe, b"").unwrap();
731+
std::fs::write(venv_dir.join("pyvenv.cfg"), b"version = 3.13.0\n").unwrap();
732+
// Explicitly NOT creating .project file
733+
734+
// Construct PythonEnv
735+
let env = PythonEnv {
736+
executable: norm_case(python_exe.clone()),
737+
prefix: Some(norm_case(venv_dir.clone())),
738+
version: None,
739+
symlinks: None,
740+
};
741+
742+
let env_vars = EnvVariables {
743+
pipenv_max_depth: 3,
744+
pipenv_pipfile: "Pipfile".to_string(),
745+
home: Some(temp_home.clone()),
746+
xdg_data_home: None,
747+
workon_home: None,
748+
path: None,
749+
};
750+
751+
// Should be detected via naming pattern fallback
752+
assert!(
753+
is_in_pipenv_centralized_dir(&env, &env_vars),
754+
"Expected env to be detected in centralized dir via naming pattern"
755+
);
756+
assert!(
757+
is_pipenv(&env, &env_vars),
758+
"Expected env to be identified as pipenv via naming pattern"
759+
);
760+
761+
// Locator should return the environment
762+
let locator = PipEnv {
763+
env_vars,
764+
pipenv_executable: Arc::new(RwLock::new(None)),
765+
};
766+
let result = locator
767+
.try_from(&env)
768+
.expect("expected locator to return environment");
769+
assert_eq!(result.kind, Some(PythonEnvironmentKind::Pipenv));
770+
// Project should be None since there's no .project file and no Pipfile nearby
771+
assert_eq!(result.project, None);
772+
773+
// Cleanup
774+
std::fs::remove_dir_all(&temp_home).ok();
775+
}
776+
777+
#[test]
778+
fn test_pipenv_naming_pattern_regex() {
779+
// Test that our regex correctly matches pipenv naming patterns
780+
// Valid patterns: {name}-{8-char-hash}
781+
assert!(PIPENV_ENV_NAME_PATTERN.is_match("myproject-AbC12xYz"));
782+
assert!(PIPENV_ENV_NAME_PATTERN.is_match("my-project-AbC12xYz"));
783+
assert!(PIPENV_ENV_NAME_PATTERN.is_match("my_project-AbC12xYz"));
784+
assert!(PIPENV_ENV_NAME_PATTERN.is_match("project123-12345678"));
785+
assert!(PIPENV_ENV_NAME_PATTERN.is_match("a-b-c-d-12345678"));
786+
// URL-safe base64 can include _ and -
787+
assert!(PIPENV_ENV_NAME_PATTERN.is_match("myproject-AbC_2-Yz"));
788+
789+
// Invalid patterns (should NOT match)
790+
assert!(!PIPENV_ENV_NAME_PATTERN.is_match("myproject")); // no hash
791+
assert!(!PIPENV_ENV_NAME_PATTERN.is_match("myproject-abc")); // hash too short (3 chars)
792+
assert!(!PIPENV_ENV_NAME_PATTERN.is_match("myproject-abcdefg")); // hash too short (7 chars)
793+
assert!(!PIPENV_ENV_NAME_PATTERN.is_match("-AbC12xYz")); // no project name
794+
}
681795
}

0 commit comments

Comments
 (0)