Skip to content

Commit c890ce8

Browse files
authored
Support for pyenv virtual-env in native locator (#23372)
1 parent 204309e commit c890ce8

File tree

8 files changed

+183
-41
lines changed

8 files changed

+183
-41
lines changed

native_locator/src/messaging.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ pub enum PythonEnvironmentCategory {
5555
Homebrew,
5656
Conda,
5757
Pyenv,
58+
PyenvVirtualEnv,
5859
WindowsStore,
5960
}
6061

native_locator/src/pyenv.rs

Lines changed: 100 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,86 @@ fn get_pyenv_version(folder_name: String) -> Option<String> {
9999
}
100100
}
101101

102+
fn report_if_pure_python_environment(
103+
executable: PathBuf,
104+
path: &PathBuf,
105+
pyenv_binary_for_activation: String,
106+
dispatcher: &mut impl messaging::MessageDispatcher,
107+
) -> Option<()> {
108+
let version = get_pyenv_version(path.file_name().unwrap().to_string_lossy().to_string())?;
109+
110+
let env_path = path.to_string_lossy().to_string();
111+
let activated_run = Some(vec![
112+
pyenv_binary_for_activation,
113+
"shell".to_string(),
114+
version.clone(),
115+
]);
116+
dispatcher.report_environment(messaging::PythonEnvironment::new(
117+
version.clone(),
118+
vec![executable.into_os_string().into_string().unwrap()],
119+
messaging::PythonEnvironmentCategory::Pyenv,
120+
Some(version),
121+
activated_run,
122+
Some(env_path.clone()),
123+
Some(env_path),
124+
));
125+
126+
Some(())
127+
}
128+
129+
#[derive(Debug)]
130+
struct PyEnvCfg {
131+
version: String,
132+
}
133+
134+
fn parse_pyenv_cfg(path: &PathBuf) -> Option<PyEnvCfg> {
135+
let cfg = path.join("pyvenv.cfg");
136+
if !fs::metadata(&cfg).is_ok() {
137+
return None;
138+
}
139+
140+
let contents = fs::read_to_string(cfg).ok()?;
141+
let version_regex = Regex::new(r"^version\s*=\s*(\d+\.\d+\.\d+)$").unwrap();
142+
for line in contents.lines() {
143+
if let Some(captures) = version_regex.captures(line) {
144+
if let Some(value) = captures.get(1) {
145+
return Some(PyEnvCfg {
146+
version: value.as_str().to_string(),
147+
});
148+
}
149+
}
150+
}
151+
None
152+
}
153+
154+
fn report_if_virtual_env_environment(
155+
executable: PathBuf,
156+
path: &PathBuf,
157+
pyenv_binary_for_activation: String,
158+
dispatcher: &mut impl messaging::MessageDispatcher,
159+
) -> Option<()> {
160+
let pyenv_cfg = parse_pyenv_cfg(path)?;
161+
let folder_name = path.file_name().unwrap().to_string_lossy().to_string();
162+
163+
let env_path = path.to_string_lossy().to_string();
164+
let activated_run = Some(vec![
165+
pyenv_binary_for_activation,
166+
"activate".to_string(),
167+
folder_name.clone(),
168+
]);
169+
dispatcher.report_environment(messaging::PythonEnvironment::new(
170+
folder_name,
171+
vec![executable.into_os_string().into_string().unwrap()],
172+
messaging::PythonEnvironmentCategory::PyenvVirtualEnv,
173+
Some(pyenv_cfg.version),
174+
activated_run,
175+
Some(env_path.clone()),
176+
Some(env_path),
177+
));
178+
179+
Some(())
180+
}
181+
102182
pub fn find_and_report(
103183
dispatcher: &mut impl messaging::MessageDispatcher,
104184
environment: &impl known::Environment,
@@ -122,35 +202,27 @@ pub fn find_and_report(
122202
for entry in fs::read_dir(&versions_dir).ok()? {
123203
if let Ok(path) = entry {
124204
let path = path.path();
125-
if path.is_dir() {
126-
if let Some(executable) = find_python_binary_path(&path) {
127-
let version =
128-
get_pyenv_version(path.file_name().unwrap().to_string_lossy().to_string());
129-
130-
// If we cannot extract version, this isn't a valid pyenv environment.
131-
// Or its one that we're not interested in.
132-
if version.is_none() {
133-
continue;
134-
}
135-
let env_path = path.to_string_lossy().to_string();
136-
let activated_run = match version.clone() {
137-
Some(version) => Some(vec![
138-
pyenv_binary_for_activation.clone(),
139-
"local".to_string(),
140-
version.clone(),
141-
]),
142-
None => None,
143-
};
144-
dispatcher.report_environment(messaging::PythonEnvironment::new(
145-
"Python".to_string(),
146-
vec![executable.into_os_string().into_string().unwrap()],
147-
messaging::PythonEnvironmentCategory::Pyenv,
148-
version,
149-
activated_run,
150-
Some(env_path.clone()),
151-
Some(env_path),
152-
));
205+
if !path.is_dir() {
206+
continue;
207+
}
208+
if let Some(executable) = find_python_binary_path(&path) {
209+
if report_if_pure_python_environment(
210+
executable.clone(),
211+
&path,
212+
pyenv_binary_for_activation.clone(),
213+
dispatcher,
214+
)
215+
.is_some()
216+
{
217+
continue;
153218
}
219+
220+
report_if_virtual_env_environment(
221+
executable.clone(),
222+
&path,
223+
pyenv_binary_for_activation.clone(),
224+
dispatcher,
225+
);
154226
}
155227
}
156228
}

native_locator/tests/common.rs

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4-
use std::{collections::HashMap, path::PathBuf};
5-
6-
use python_finder::{known::Environment, messaging::{EnvManager, MessageDispatcher, PythonEnvironment}};
4+
use python_finder::{
5+
known::Environment,
6+
messaging::{EnvManager, MessageDispatcher, PythonEnvironment},
7+
};
78
use serde_json::Value;
9+
use std::{collections::HashMap, path::PathBuf};
810

911
#[allow(dead_code)]
1012
pub fn test_file_path(paths: &[&str]) -> String {
@@ -16,7 +18,6 @@ pub fn test_file_path(paths: &[&str]) -> String {
1618
root.to_string_lossy().to_string()
1719
}
1820

19-
2021
#[allow(dead_code)]
2122
pub fn join_test_paths(paths: &[&str]) -> String {
2223
let path: PathBuf = paths.iter().map(|p| p.to_string()).collect();
@@ -85,8 +86,49 @@ pub fn create_test_environment(
8586
}
8687
}
8788

89+
fn compare_json(expected: &Value, actual: &Value) -> bool {
90+
if expected == actual {
91+
return true;
92+
}
93+
94+
if expected.is_object() {
95+
let expected = expected.as_object().unwrap();
96+
let actual = actual.as_object().unwrap();
97+
98+
for (key, value) in expected.iter() {
99+
if !actual.contains_key(key) {
100+
return false;
101+
}
102+
103+
if !compare_json(value, actual.get(key).unwrap()) {
104+
return false;
105+
}
106+
}
107+
return true;
108+
}
109+
110+
if expected.is_array() {
111+
let expected = expected.as_array().unwrap();
112+
let actual = actual.as_array().unwrap();
113+
114+
if expected.len() != actual.len() {
115+
return false;
116+
}
117+
118+
for (i, value) in expected.iter().enumerate() {
119+
if !compare_json(value, actual.get(i).unwrap()) {
120+
return false;
121+
}
122+
}
123+
return true;
124+
}
125+
126+
false
127+
}
128+
88129
#[allow(dead_code)]
89130
pub fn assert_messages(expected_json: &[Value], dispatcher: &TestDispatcher) {
131+
let mut expected_json = expected_json.to_vec();
90132
assert_eq!(
91133
expected_json.len(),
92134
dispatcher.messages.len(),
@@ -97,6 +139,26 @@ pub fn assert_messages(expected_json: &[Value], dispatcher: &TestDispatcher) {
97139
return;
98140
}
99141

100-
let actual: serde_json::Value = serde_json::from_str(dispatcher.messages[0].as_str()).unwrap();
101-
assert_eq!(expected_json[0], actual);
142+
// Ignore the order of the json items when comparing.
143+
for actual in dispatcher.messages.iter() {
144+
let actual: serde_json::Value = serde_json::from_str(actual.as_str()).unwrap();
145+
146+
let mut valid_index: Option<usize> = None;
147+
for (i, expected) in expected_json.iter().enumerate() {
148+
if !compare_json(expected, &actual) {
149+
continue;
150+
}
151+
152+
// Ensure we verify using standard assert_eq!, just in case the code is faulty..
153+
valid_index = Some(i);
154+
assert_eq!(expected, &actual);
155+
}
156+
if let Some(index) = valid_index {
157+
// This is to ensure we don't compare the same item twice.
158+
expected_json.remove(index);
159+
} else {
160+
// Use traditional assert so we can see the fully output in the test results.
161+
assert_eq!(expected_json[0], actual);
162+
}
163+
}
102164
}

native_locator/tests/conda_test.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,8 @@ fn finds_two_conda_envs_from_txt() {
8181
let conda_2_exe = join_test_paths(&[conda_2.clone().as_str(), "python"]);
8282

8383
let expected_conda_env = json!({"executablePath":[conda_exe.clone()],"version":null});
84-
let expected_conda_1 = json!({"name":"envs/one","pythonExecutablePath":[conda_1_exe.clone()],"category":"conda","version":"10.0.1","activatedRun":[conda_exe.clone(),"run","-n","envs/one","python"],"envPath":conda_1.clone()});
85-
let expected_conda_2 = json!({"name":"envs/two","pythonExecutablePath":[conda_2_exe.clone()],"category":"conda","version":null,"activatedRun":[conda_exe.clone(),"run","-n","envs/two","python"],"envPath":conda_2.clone()});
84+
let expected_conda_1 = json!({"name":"envs/one","pythonExecutablePath":[conda_1_exe.clone()],"category":"conda","version":"10.0.1","activatedRun":[conda_exe.clone(),"run","-n","envs/one","python"],"envPath":conda_1.clone(), "sysPrefixPath":conda_1.clone()});
85+
let expected_conda_2 = json!({"name":"envs/two","pythonExecutablePath":[conda_2_exe.clone()],"category":"conda","version":null,"activatedRun":[conda_exe.clone(),"run","-n","envs/two","python"],"envPath":conda_2.clone(), "sysPrefixPath":conda_2.clone()});
8686
assert_messages(
8787
&[expected_conda_env, expected_conda_1, expected_conda_2],
8888
&dispatcher,

native_locator/tests/pyenv_test.rs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,16 +73,18 @@ fn find_pyenv_envs() {
7373

7474
pyenv::find_and_report(&mut dispatcher, &known);
7575

76-
assert_eq!(dispatcher.messages.len(), 5);
76+
assert_eq!(dispatcher.messages.len(), 6);
7777
let expected_manager = json!({ "executablePath": [pyenv_exe.clone()], "version": null });
78-
let expected_3_9_9 = json!({"name": "Python","pythonExecutablePath": [join_test_paths(&[home.as_str(), ".pyenv/versions/3.9.9/bin/python"])],"category": "pyenv","version": "3.9.9","activatedRun": [pyenv_exe.clone(), "local", "3.9.9"],"envPath": join_test_paths(&[home.as_str(), ".pyenv/versions/3.9.9"]),"sysPrefixPath": join_test_paths(&[home.as_str(), ".pyenv/versions/3.9.9/bin/python"])});
79-
let expected_3_12_1 = json!({"name": "Python","pythonExecutablePath": [join_test_paths(&[home.as_str(), ".pyenv/versions/3.12.1/bin/python"])],"category": "pyenv","version": "3.12.1","activatedRun": [pyenv_exe.clone(), "local", "3.12.1"],"envPath": join_test_paths(&[home.as_str(), ".pyenv/versions/3.12.1"]),"sysPrefixPath": join_test_paths(&[home.as_str(), ".pyenv/versions/3.12.1/bin/python"])});
80-
let expected_3_13_dev = json!({"name": "Python","pythonExecutablePath": [join_test_paths(&[home.as_str(), ".pyenv/versions/3.13-dev/bin/python"])],"category": "pyenv","version": "3.13-dev","activatedRun": [pyenv_exe.clone(), "local", "3.13-dev"],"envPath": join_test_paths(&[home.as_str(), ".pyenv/versions/3.13-dev"]),"sysPrefixPath": join_test_paths(&[home.as_str(), ".pyenv/versions/3.13-dev/bin/python"])});
81-
let expected_3_12_1a3 = json!({"name": "Python","pythonExecutablePath": [join_test_paths(&[home.as_str(), ".pyenv/versions/3.12.1a3/bin/python"])],"category": "pyenv","version": "3.12.1a3","activatedRun": [pyenv_exe.clone(), "local", "3.12.1a3"],"envPath": join_test_paths(&[home.as_str(), ".pyenv/versions/3.12.1a3"]),"sysPrefixPath": join_test_paths(&[home.as_str(), ".pyenv/versions/3.12.1a3/bin/python"])});
78+
let expected_3_9_9 = json!({"name": "3.9.9","pythonExecutablePath": [join_test_paths(&[home.as_str(), ".pyenv/versions/3.9.9/bin/python"])],"category": "pyenv","version": "3.9.9","activatedRun": [pyenv_exe.clone(), "shell", "3.9.9"],"envPath": join_test_paths(&[home.as_str(), ".pyenv/versions/3.9.9"]),"sysPrefixPath": join_test_paths(&[home.as_str(), ".pyenv/versions/3.9.9"])});
79+
let expected_virtual_env = json!( {"name": "my-virtual-env", "version": "3.10.13", "activatedRun": [pyenv_exe.clone(), "activate", "my-virtual-env"], "category": "pyenvVirtualEnv", "envPath": join_test_paths(&[home.as_str(),".pyenv/versions/my-virtual-env"]), "pythonExecutablePath": [join_test_paths(&[home.as_str(),".pyenv/versions/my-virtual-env/bin/python"])], "sysPrefixPath": join_test_paths(&[home.as_str(),".pyenv/versions/my-virtual-env"])});
80+
let expected_3_12_1 = json!({"name": "3.12.1","pythonExecutablePath": [join_test_paths(&[home.as_str(), ".pyenv/versions/3.12.1/bin/python"])],"category": "pyenv","version": "3.12.1","activatedRun": [pyenv_exe.clone(), "shell", "3.12.1"],"envPath": join_test_paths(&[home.as_str(), ".pyenv/versions/3.12.1"]),"sysPrefixPath": join_test_paths(&[home.as_str(), ".pyenv/versions/3.12.1"])});
81+
let expected_3_13_dev = json!({"name": "3.13-dev","pythonExecutablePath": [join_test_paths(&[home.as_str(), ".pyenv/versions/3.13-dev/bin/python"])],"category": "pyenv","version": "3.13-dev","activatedRun": [pyenv_exe.clone(), "shell", "3.13-dev"],"envPath": join_test_paths(&[home.as_str(), ".pyenv/versions/3.13-dev"]),"sysPrefixPath": join_test_paths(&[home.as_str(), ".pyenv/versions/3.13-dev"])});
82+
let expected_3_12_1a3 = json!({"name": "3.12.1a3","pythonExecutablePath": [join_test_paths(&[home.as_str(), ".pyenv/versions/3.12.1a3/bin/python"])],"category": "pyenv","version": "3.12.1a3","activatedRun": [pyenv_exe.clone(), "shell", "3.12.1a3"],"envPath": join_test_paths(&[home.as_str(), ".pyenv/versions/3.12.1a3"]),"sysPrefixPath": join_test_paths(&[home.as_str(), ".pyenv/versions/3.12.1a3"])});
8283
assert_messages(
8384
&[
8485
expected_manager,
8586
expected_3_9_9,
87+
expected_virtual_env,
8688
expected_3_12_1,
8789
expected_3_13_dev,
8890
expected_3_12_1a3,

native_locator/tests/unix/pyenv/.pyenv/versions/my-virtual-env/bin/python

Whitespace-only changes.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
home = /Users/donjayamanne/.pyenv/versions/3.10.13/bin
2+
include-system-site-packages = false
3+
version = 3.10.13

src/client/pythonEnvironments/base/locators/lowLevel/nativeLocator.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ function categoryToKind(category: string): PythonEnvKind {
4343
return PythonEnvKind.System;
4444
case 'pyenv':
4545
return PythonEnvKind.Pyenv;
46+
case 'pyenvvirtualenv':
47+
return PythonEnvKind.VirtualEnv;
4648
case 'windowsstore':
4749
return PythonEnvKind.MicrosoftStore;
4850
default: {

0 commit comments

Comments
 (0)