Skip to content

Commit 4333fc0

Browse files
authored
Implement global virtual environment locator (microsoft#14416)
* Add Global virtual environments locator * Fix tests and address comments * Add missing files * Address comments * Move envs * Test Fix * Address comments * Address more comments * Try a different approach * . * . * Remove version parsing. * more tweaks
1 parent 25fd710 commit 4333fc0

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+698
-297
lines changed
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import * as fsapi from 'fs-extra';
5+
import * as path from 'path';
6+
import { chain, iterable } from '../../common/utils/async';
7+
import { getOSType, OSType } from '../../common/utils/platform';
8+
import { isPosixPythonBin } from './posixUtils';
9+
import { isWindowsPythonExe } from './windowsUtils';
10+
11+
export async function* findInterpretersInDir(root:string, recurseLevels?:number): AsyncIterableIterator<string> {
12+
const dirContents = (await fsapi.readdir(root)).map((c) => path.join(root, c));
13+
const os = getOSType();
14+
const checkBin = os === OSType.Windows ? isWindowsPythonExe : isPosixPythonBin;
15+
const generators = dirContents.map((item) => {
16+
async function* generator() {
17+
const stat = await fsapi.lstat(item);
18+
19+
if (stat.isDirectory()) {
20+
if (recurseLevels && recurseLevels > 0) {
21+
const subItems = findInterpretersInDir(item, recurseLevels - 1);
22+
23+
for await (const subItem of subItems) {
24+
yield subItem;
25+
}
26+
}
27+
} else if (checkBin(item)) {
28+
yield item;
29+
}
30+
}
31+
32+
return generator();
33+
});
34+
35+
yield* iterable(chain(generators));
36+
}

src/client/pythonEnvironments/common/environmentIdentifier.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,7 @@
44
import { isCondaEnvironment } from '../discovery/locators/services/condaLocator';
55
import { isPipenvEnvironment } from '../discovery/locators/services/pipEnvHelper';
66
import { isPyenvEnvironment } from '../discovery/locators/services/pyenvLocator';
7-
import { isVenvEnvironment } from '../discovery/locators/services/venvLocator';
8-
import { isVirtualenvEnvironment } from '../discovery/locators/services/virtualenvLocator';
9-
import { isVirtualenvwrapperEnvironment } from '../discovery/locators/services/virtualenvwrapperLocator';
7+
import { isVenvEnvironment, isVirtualenvEnvironment, isVirtualenvwrapperEnvironment } from '../discovery/locators/services/virtualEnvironmentIdentifier';
108
import { isWindowsStoreEnvironment } from '../discovery/locators/services/windowsStoreLocator';
119
import { EnvironmentType } from '../info';
1210

src/client/pythonEnvironments/common/virtualenvwrapperUtils.ts

Lines changed: 0 additions & 14 deletions
This file was deleted.
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import * as fsapi from 'fs-extra';
5+
import { toUpper, uniq } from 'lodash';
6+
import * as path from 'path';
7+
import { traceVerbose } from '../../../../common/logger';
8+
import { chain, iterable } from '../../../../common/utils/async';
9+
import {
10+
getEnvironmentVariable, getOSType, getUserHomeDir, OSType,
11+
} from '../../../../common/utils/platform';
12+
import {
13+
PythonEnvInfo, PythonEnvKind, UNKNOWN_PYTHON_VERSION,
14+
} from '../../../base/info';
15+
import { buildEnvInfo } from '../../../base/info/env';
16+
import { ILocator, IPythonEnvsIterator } from '../../../base/locator';
17+
import { PythonEnvsWatcher } from '../../../base/watcher';
18+
import { findInterpretersInDir } from '../../../common/commonUtils';
19+
import { getFileInfo, pathExists } from '../../../common/externalDependencies';
20+
import { isVenvEnvironment, isVirtualenvEnvironment, isVirtualenvwrapperEnvironment } from './virtualEnvironmentIdentifier';
21+
22+
const DEFAULT_SEARCH_DEPTH = 2;
23+
24+
/**
25+
* Gets all default virtual environment locations. This uses WORKON_HOME,
26+
* and user home directory to find some known locations where global virtual
27+
* environments are often created.
28+
*/
29+
async function getGlobalVirtualEnvDirs(): Promise<string[]> {
30+
const venvDirs:string[] = [];
31+
32+
const workOnHome = getEnvironmentVariable('WORKON_HOME');
33+
if (workOnHome && await pathExists(workOnHome)) {
34+
venvDirs.push(workOnHome);
35+
}
36+
37+
const homeDir = getUserHomeDir();
38+
if (homeDir && await pathExists(homeDir)) {
39+
const os = getOSType();
40+
let subDirs = ['Envs', 'envs', '.direnv', '.venvs', '.virtualenvs'];
41+
if (os === OSType.Windows) {
42+
subDirs = uniq(subDirs.map(toUpper));
43+
}
44+
45+
(await fsapi.readdir(homeDir))
46+
.filter((d) => subDirs.includes(os === OSType.Windows ? d.toUpperCase() : d))
47+
.forEach((d) => venvDirs.push(path.join(homeDir, d)));
48+
}
49+
50+
return venvDirs;
51+
}
52+
53+
/**
54+
* Gets the virtual environment kind for a given interpreter path.
55+
* This only checks for environments created using venv, virtualenv,
56+
* and virtualenvwrapper based environments.
57+
* @param interpreterPath: Absolute path to the interpreter paths.
58+
*/
59+
async function getVirtualEnvKind(interpreterPath:string): Promise<PythonEnvKind> {
60+
if (await isVenvEnvironment(interpreterPath)) {
61+
return PythonEnvKind.Venv;
62+
}
63+
64+
if (await isVirtualenvwrapperEnvironment(interpreterPath)) {
65+
return PythonEnvKind.VirtualEnvWrapper;
66+
}
67+
68+
if (await isVirtualenvEnvironment(interpreterPath)) {
69+
return PythonEnvKind.VirtualEnv;
70+
}
71+
72+
return PythonEnvKind.Unknown;
73+
}
74+
75+
/**
76+
* Finds and resolves virtual environments created in known global locations.
77+
*/
78+
export class GlobalVirtualEnvironmentLocator extends PythonEnvsWatcher implements ILocator {
79+
private virtualEnvKinds = [
80+
PythonEnvKind.Venv,
81+
PythonEnvKind.VirtualEnv,
82+
PythonEnvKind.VirtualEnvWrapper,
83+
];
84+
85+
public constructor(private readonly searchDepth?:number) {
86+
super();
87+
}
88+
89+
public iterEnvs(): IPythonEnvsIterator {
90+
// Number of levels of sub-directories to recurse when looking for
91+
// interpreters
92+
const searchDepth = this.searchDepth ?? DEFAULT_SEARCH_DEPTH;
93+
94+
async function* iterator(virtualEnvKinds:PythonEnvKind[]) {
95+
const envRootDirs = await getGlobalVirtualEnvDirs();
96+
const envGenerators = envRootDirs.map((envRootDir) => {
97+
async function* generator() {
98+
traceVerbose(`Searching for global virtual envs in: ${envRootDir}`);
99+
100+
const envGenerator = findInterpretersInDir(envRootDir, searchDepth);
101+
102+
for await (const env of envGenerator) {
103+
// We only care about python.exe (on windows) and python (on linux/mac)
104+
// Other version like python3.exe or python3.8 are often symlinks to
105+
// python.exe or python in the same directory in the case of virtual
106+
// environments.
107+
const name = path.basename(env).toLowerCase();
108+
if (name === 'python.exe' || name === 'python') {
109+
// We should extract the kind here to avoid doing is*Environment()
110+
// check multiple times. Those checks are file system heavy and
111+
// we can use the kind to determine this anyway.
112+
const kind = await getVirtualEnvKind(env);
113+
114+
const timeData = await getFileInfo(env);
115+
if (virtualEnvKinds.includes(kind)) {
116+
traceVerbose(`Global Virtual Environment: [added] ${env}`);
117+
const envInfo = buildEnvInfo({
118+
kind,
119+
executable: env,
120+
version: UNKNOWN_PYTHON_VERSION,
121+
});
122+
envInfo.executable.ctime = timeData.ctime;
123+
envInfo.executable.mtime = timeData.mtime;
124+
yield envInfo;
125+
} else {
126+
traceVerbose(`Global Virtual Environment: [skipped] ${env}`);
127+
}
128+
} else {
129+
traceVerbose(`Global Virtual Environment: [skipped] ${env}`);
130+
}
131+
}
132+
}
133+
return generator();
134+
});
135+
136+
yield* iterable(chain(envGenerators));
137+
}
138+
139+
return iterator(this.virtualEnvKinds);
140+
}
141+
142+
public async resolveEnv(env: string | PythonEnvInfo): Promise<PythonEnvInfo | undefined> {
143+
const executablePath = typeof env === 'string' ? env : env.executable.filename;
144+
if (await pathExists(executablePath)) {
145+
// We should extract the kind here to avoid doing is*Environment()
146+
// check multiple times. Those checks are file system heavy and
147+
// we can use the kind to determine this anyway.
148+
const kind = await getVirtualEnvKind(executablePath);
149+
if (this.virtualEnvKinds.includes(kind)) {
150+
const timeData = await getFileInfo(executablePath);
151+
const envInfo = buildEnvInfo({
152+
kind,
153+
version: UNKNOWN_PYTHON_VERSION,
154+
executable: executablePath,
155+
});
156+
envInfo.executable.ctime = timeData.ctime;
157+
envInfo.executable.mtime = timeData.mtime;
158+
return envInfo;
159+
}
160+
}
161+
return undefined;
162+
}
163+
}

src/client/pythonEnvironments/discovery/locators/services/venvLocator.ts

Lines changed: 0 additions & 30 deletions
This file was deleted.
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import * as fsapi from 'fs-extra';
5+
import * as path from 'path';
6+
import {
7+
getEnvironmentVariable, getOSType, getUserHomeDir, OSType,
8+
} from '../../../../common/utils/platform';
9+
import { pathExists } from '../../../common/externalDependencies';
10+
11+
/**
12+
* Checks if the given interpreter belongs to a venv based environment.
13+
* @param {string} interpreterPath: Absolute path to the python interpreter.
14+
* @returns {boolean} : Returns true if the interpreter belongs to a venv environment.
15+
*/
16+
export async function isVenvEnvironment(interpreterPath:string): Promise<boolean> {
17+
const pyvenvConfigFile = 'pyvenv.cfg';
18+
19+
// Check if the pyvenv.cfg file is in the parent directory relative to the interpreter.
20+
// env
21+
// |__ pyvenv.cfg <--- check if this file exists
22+
// |__ bin or Scripts
23+
// |__ python <--- interpreterPath
24+
const venvPath1 = path.join(path.dirname(path.dirname(interpreterPath)), pyvenvConfigFile);
25+
26+
// Check if the pyvenv.cfg file is in the directory as the interpreter.
27+
// env
28+
// |__ pyvenv.cfg <--- check if this file exists
29+
// |__ python <--- interpreterPath
30+
const venvPath2 = path.join(path.dirname(interpreterPath), pyvenvConfigFile);
31+
32+
// The paths are ordered in the most common to least common
33+
const venvPaths = [venvPath1, venvPath2];
34+
35+
// We don't need to test all at once, testing each one here
36+
for (const venvPath of venvPaths) {
37+
if (await pathExists(venvPath)) {
38+
return true;
39+
}
40+
}
41+
return false;
42+
}
43+
44+
/**
45+
* Checks if the given interpreter belongs to a virtualenv based environment.
46+
* @param {string} interpreterPath: Absolute path to the python interpreter.
47+
* @returns {boolean} : Returns true if the interpreter belongs to a virtualenv environment.
48+
*/
49+
export async function isVirtualenvEnvironment(interpreterPath:string): Promise<boolean> {
50+
// Check if there are any activate.* files in the same directory as the interpreter.
51+
//
52+
// env
53+
// |__ activate, activate.* <--- check if any of these files exist
54+
// |__ python <--- interpreterPath
55+
const directory = path.dirname(interpreterPath);
56+
const files = await fsapi.readdir(directory);
57+
const regex = /^activate(\.([A-z]|\d)+)?$/i;
58+
59+
return files.find((file) => regex.test(file)) !== undefined;
60+
}
61+
62+
async function getDefaultVirtualenvwrapperDir(): Promise<string> {
63+
const homeDir = getUserHomeDir() || '';
64+
65+
// In Windows, the default path for WORKON_HOME is %USERPROFILE%\Envs.
66+
// If 'Envs' is not available we should default to '.virtualenvs'. Since that
67+
// is also valid for windows.
68+
if (getOSType() === OSType.Windows) {
69+
// ~/Envs with uppercase 'E' is the default home dir for
70+
// virtualEnvWrapper.
71+
const envs = path.join(homeDir, 'Envs');
72+
if (await pathExists(envs)) {
73+
return envs;
74+
}
75+
}
76+
return path.join(homeDir, '.virtualenvs');
77+
}
78+
79+
function getWorkOnHome(): Promise<string> {
80+
// The WORKON_HOME variable contains the path to the root directory of all virtualenvwrapper environments.
81+
// If the interpreter path belongs to one of them then it is a virtualenvwrapper type of environment.
82+
const workOnHome = getEnvironmentVariable('WORKON_HOME');
83+
if (workOnHome) {
84+
return Promise.resolve(workOnHome);
85+
}
86+
return getDefaultVirtualenvwrapperDir();
87+
}
88+
89+
/**
90+
* Checks if the given interpreter belongs to a virtualenvWrapper based environment.
91+
* @param {string} interpreterPath: Absolute path to the python interpreter.
92+
* @returns {boolean}: Returns true if the interpreter belongs to a virtualenvWrapper environment.
93+
*/
94+
export async function isVirtualenvwrapperEnvironment(interpreterPath:string): Promise<boolean> {
95+
const workOnHomeDir = await getWorkOnHome();
96+
let pathToCheck = interpreterPath;
97+
let workOnRoot = workOnHomeDir;
98+
99+
if (getOSType() === OSType.Windows) {
100+
workOnRoot = workOnHomeDir.toUpperCase();
101+
pathToCheck = interpreterPath.toUpperCase();
102+
}
103+
104+
// For environment to be a virtualenvwrapper based it has to follow these two rules:
105+
// 1. It should be in a sub-directory under the WORKON_HOME
106+
// 2. It should be a valid virtualenv environment
107+
return await pathExists(workOnHomeDir)
108+
&& pathToCheck.startsWith(`${workOnRoot}${path.sep}`)
109+
&& isVirtualenvEnvironment(interpreterPath);
110+
}

src/client/pythonEnvironments/discovery/locators/services/virtualenvLocator.ts

Lines changed: 0 additions & 23 deletions
This file was deleted.

0 commit comments

Comments
 (0)