Skip to content

Commit 6074946

Browse files
authored
Add pyenv locator (#14587)
* Add pyenv locator * Minor tweak * More minor corrections * More clean up * Address comments add more tests * Fix linting. * Fix typo * More review comments * Remove export on getPythonVersionSpecificity
1 parent c41c08f commit 6074946

31 files changed

Lines changed: 1263 additions & 59 deletions

File tree

src/client/pythonEnvironments/base/info/env.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ export function areSameEnv(
179179
* weighted by most important to least important fields.
180180
* Wn > Wn-1 + Wn-2 + ... W0
181181
*/
182-
function getPythonVersionInfoHeuristic(version: PythonVersion): number {
182+
function getPythonVersionSpecificity(version: PythonVersion): number {
183183
let infoLevel = 0;
184184
if (version.major > 0) {
185185
infoLevel += 20; // W4
@@ -204,6 +204,15 @@ function getPythonVersionInfoHeuristic(version: PythonVersion): number {
204204
return infoLevel;
205205
}
206206

207+
/**
208+
* Compares two python versions, based on the amount of data each object has. If versionA has
209+
* less information then the returned value is negative. If it is same then 0. If versionA has
210+
* more information then positive.
211+
*/
212+
export function comparePythonVersionSpecificity(versionA: PythonVersion, versionB: PythonVersion): number {
213+
return Math.sign(getPythonVersionSpecificity(versionA) - getPythonVersionSpecificity(versionB));
214+
}
215+
207216
/**
208217
* Returns a heuristic value on how much information is available in the given executable object.
209218
* @param {FileInfo} executable executable object to generate heuristic from.
@@ -267,7 +276,7 @@ export function mergeEnvironments(target: PythonEnvInfo, other: PythonEnvInfo):
267276
const merged = cloneDeep(target);
268277

269278
const version = cloneDeep(
270-
getPythonVersionInfoHeuristic(target.version) > getPythonVersionInfoHeuristic(other.version)
279+
getPythonVersionSpecificity(target.version) > getPythonVersionSpecificity(other.version)
271280
? target.version
272281
: other.version,
273282
);

src/client/pythonEnvironments/base/info/pythonVersion.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ export function getPythonVersionFromPath(exe:string): PythonVersion {
1818

1919
/**
2020
* Convert the given string into the corresponding Python version object.
21+
* Example:
22+
* 3.9.0
23+
* 3.9.0a1
24+
* 3.9.0b2
25+
* 3.9.0rc1
26+
*
27+
* Does not parse:
28+
* 3.9.0.final.0
2129
*/
2230
export function parseVersion(versionStr: string): PythonVersion {
2331
const parsed = parseBasicVersionInfo<PythonVersion>(versionStr);
@@ -49,6 +57,47 @@ export function parseVersion(versionStr: string): PythonVersion {
4957
return version;
5058
}
5159

60+
/**
61+
* Convert the given string into the corresponding Python version object.
62+
* Example:
63+
* 3.9.0.final.0
64+
* 3.9.0.alpha.1
65+
* 3.9.0.beta.2
66+
* 3.9.0.candidate.1
67+
*
68+
* Does not parse:
69+
* 3.9.0
70+
* 3.9.0a1
71+
* 3.9.0b2
72+
* 3.9.0rc1
73+
*/
74+
export function parseVersionInfo(versionInfoStr: string): PythonVersion {
75+
const parts = versionInfoStr.split('.');
76+
const version = UNKNOWN_PYTHON_VERSION;
77+
if (parts.length >= 2) {
78+
version.major = parseInt(parts[0], 10);
79+
version.minor = parseInt(parts[1], 10);
80+
}
81+
82+
if (parts.length >= 3) {
83+
version.micro = parseInt(parts[2], 10);
84+
}
85+
86+
if (parts.length >= 4 && version.release) {
87+
const levels = ['alpha', 'beta', 'candidate', 'final'];
88+
const level = parts[3].toLowerCase();
89+
if (levels.includes(level)) {
90+
version.release.level = level as PythonReleaseLevel;
91+
}
92+
}
93+
94+
if (parts.length >= 5 && version.release) {
95+
version.release.serial = parseInt(parts[4], 10);
96+
}
97+
98+
return version;
99+
}
100+
52101
/**
53102
* Checks if all the fields in the version object match.
54103
* @param {PythonVersion} left

src/client/pythonEnvironments/base/locators/composite/environmentsReducer.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -144,10 +144,10 @@ function sortEnvInfoByPriority(...envs: PythonEnvInfo[]): PythonEnvInfo[] {
144144
* configure the environment, and the fall back for identification.
145145
* Top level we have the following environment types, since they leave a unique signature
146146
* in the environment or * use a unique path for the environments they create.
147-
* 1. Conda
148-
* 2. Windows Store
149-
* 3. PipEnv
150-
* 4. Pyenv
147+
* 1. Pyenv (pyenv can also be a conda env or venv, but should be activated as a venv)
148+
* 2. Conda
149+
* 3. Windows Store
150+
* 4. PipEnv
151151
* 5. Poetry
152152
*
153153
* Next level we have the following virtual environment tools. The are here because they
@@ -160,11 +160,11 @@ function sortEnvInfoByPriority(...envs: PythonEnvInfo[]): PythonEnvInfo[] {
160160
*/
161161
function getPrioritizedEnvironmentKind(): PythonEnvKind[] {
162162
return [
163+
PythonEnvKind.Pyenv,
163164
PythonEnvKind.CondaBase,
164165
PythonEnvKind.Conda,
165166
PythonEnvKind.WindowsStore,
166167
PythonEnvKind.Pipenv,
167-
PythonEnvKind.Pyenv,
168168
PythonEnvKind.Poetry,
169169
PythonEnvKind.Venv,
170170
PythonEnvKind.VirtualEnvWrapper,

src/client/pythonEnvironments/common/commonUtils.ts

Lines changed: 116 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,27 +5,46 @@ import * as fsapi from 'fs-extra';
55
import * as path from 'path';
66
import { chain, iterable } from '../../common/utils/async';
77
import { getOSType, OSType } from '../../common/utils/platform';
8+
import { PythonVersion, UNKNOWN_PYTHON_VERSION } from '../base/info';
9+
import { comparePythonVersionSpecificity } from '../base/info/env';
10+
import { parseVersion } from '../base/info/pythonVersion';
11+
import { getPythonVersionFromConda } from '../discovery/locators/services/condaLocator';
12+
import { getPythonVersionFromVenv } from '../discovery/locators/services/virtualEnvironmentIdentifier';
813
import { isPosixPythonBin } from './posixUtils';
914
import { isWindowsPythonExe } from './windowsUtils';
1015

11-
export async function* findInterpretersInDir(root:string, recurseLevels?:number): AsyncIterableIterator<string> {
12-
const dirContents = (await fsapi.readdir(root)).map((c) => path.join(root, c));
16+
/**
17+
* Searches recursively under the given `root` directory for python interpreters.
18+
* @param root : Directory where the search begins.
19+
* @param recurseLevels : Number of levels to search for from the root directory.
20+
* @param filter : Callback that identifies directories to ignore.
21+
*/
22+
export async function* findInterpretersInDir(
23+
root: string,
24+
recurseLevels?: number,
25+
filter?: (x: string) => boolean,
26+
): AsyncIterableIterator<string> {
1327
const os = getOSType();
1428
const checkBin = os === OSType.Windows ? isWindowsPythonExe : isPosixPythonBin;
29+
const itemFilter = filter ?? (() => true);
30+
31+
const dirContents = (await fsapi.readdir(root)).filter(itemFilter);
32+
1533
const generators = dirContents.map((item) => {
1634
async function* generator() {
17-
const stat = await fsapi.lstat(item);
35+
const fullPath = path.join(root, item);
36+
const stat = await fsapi.lstat(fullPath);
1837

1938
if (stat.isDirectory()) {
2039
if (recurseLevels && recurseLevels > 0) {
21-
const subItems = findInterpretersInDir(item, recurseLevels - 1);
40+
const subItems = findInterpretersInDir(fullPath, recurseLevels - 1);
2241

2342
for await (const subItem of subItems) {
2443
yield subItem;
2544
}
2645
}
27-
} else if (checkBin(item)) {
28-
yield item;
46+
} else if (checkBin(fullPath)) {
47+
yield fullPath;
2948
}
3049
}
3150

@@ -34,3 +53,94 @@ export async function* findInterpretersInDir(root:string, recurseLevels?:number)
3453

3554
yield* iterable(chain(generators));
3655
}
56+
57+
/**
58+
* Looks for files in the same directory which might have version in their name.
59+
* @param interpreterPath
60+
*/
61+
export async function getPythonVersionFromNearByFiles(interpreterPath:string): Promise<PythonVersion> {
62+
const root = path.dirname(interpreterPath);
63+
let version = UNKNOWN_PYTHON_VERSION;
64+
for await (const interpreter of findInterpretersInDir(root)) {
65+
try {
66+
const curVersion = parseVersion(path.basename(interpreter));
67+
if (comparePythonVersionSpecificity(curVersion, version) > 0) {
68+
version = curVersion;
69+
}
70+
} catch (ex) {
71+
// Ignore any parse errors
72+
}
73+
}
74+
return version;
75+
}
76+
77+
/**
78+
* This function does the best effort of finding version of python without running the
79+
* python binary.
80+
* @param interpreterPath Absolute path to the interpreter.
81+
* @param hint Any string that might contain version info.
82+
*/
83+
export async function getPythonVersionFromPath(
84+
interpreterPath: string | undefined,
85+
hint: string | undefined,
86+
): Promise<PythonVersion> {
87+
let versionA;
88+
try {
89+
versionA = hint ? parseVersion(hint) : UNKNOWN_PYTHON_VERSION;
90+
} catch (ex) {
91+
versionA = UNKNOWN_PYTHON_VERSION;
92+
}
93+
const versionB = interpreterPath ? await getPythonVersionFromNearByFiles(interpreterPath) : UNKNOWN_PYTHON_VERSION;
94+
const versionC = interpreterPath ? await getPythonVersionFromVenv(interpreterPath) : UNKNOWN_PYTHON_VERSION;
95+
const versionD = interpreterPath ? await getPythonVersionFromConda(interpreterPath) : UNKNOWN_PYTHON_VERSION;
96+
97+
let version = UNKNOWN_PYTHON_VERSION;
98+
for (const v of [versionA, versionB, versionC, versionD]) {
99+
version = comparePythonVersionSpecificity(version, v) > 0 ? version : v;
100+
}
101+
return version;
102+
}
103+
104+
/**
105+
* This function looks specifically for 'python' or 'python.exe' binary in the sub folders of a given
106+
* environment directory.
107+
* @param envDir Absolute path to the environment directory
108+
*/
109+
export async function getInterpreterPathFromDir(envDir: string): Promise<string|undefined> {
110+
// Ignore any folders or files that not directly python binary related.
111+
function filter(str:string):boolean {
112+
const lower = str.toLowerCase();
113+
return ['bin', 'scripts'].includes(lower) || lower.search('python') >= 0;
114+
}
115+
116+
// Search in the sub-directories for python binary
117+
for await (const bin of findInterpretersInDir(envDir, 2, filter)) {
118+
const base = path.basename(bin).toLowerCase();
119+
if (base === 'python.exe' || base === 'python') {
120+
return bin;
121+
}
122+
}
123+
return undefined;
124+
}
125+
126+
/**
127+
* Gets the root environment directory based on the absolute path to the python
128+
* interpreter binary.
129+
* @param interpreterPath Absolute path to the python interpreter
130+
*/
131+
export function getEnvironmentDirFromPath(interpreterPath:string): string {
132+
const skipDirs = ['bin', 'scripts'];
133+
134+
// env <--- Return this directory if it is not 'bin' or 'scripts'
135+
// |__ python <--- interpreterPath
136+
const dir = path.basename(path.dirname(interpreterPath));
137+
if (!skipDirs.includes(dir.toLowerCase())) {
138+
return path.dirname(interpreterPath);
139+
}
140+
141+
// This is the best next guess.
142+
// env <--- Return this directory if it is not 'bin' or 'scripts'
143+
// |__ bin or Scripts
144+
// |__ python <--- interpreterPath
145+
return path.dirname(path.dirname(interpreterPath));
146+
}

src/client/pythonEnvironments/common/environmentIdentifier.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ import { EnvironmentType } from '../info';
1616
* configure the environment, and the fall back for identification.
1717
* Top level we have the following environment types, since they leave a unique signature
1818
* in the environment or * use a unique path for the environments they create.
19-
* 1. Conda
20-
* 2. Windows Store
21-
* 3. PipEnv
22-
* 4. Pyenv
19+
* 1. Pyenv (pyenv can also be a conda env or venv, but should be activated as a venv)
20+
* 2. Conda
21+
* 3. Windows Store
22+
* 4. PipEnv
2323
* 5. Poetry
2424
*
2525
* Next level we have the following virtual environment tools. The are here because they
@@ -32,10 +32,10 @@ import { EnvironmentType } from '../info';
3232
*/
3333
export function getPrioritizedEnvironmentType(): EnvironmentType[] {
3434
return [
35+
EnvironmentType.Pyenv,
3536
EnvironmentType.Conda,
3637
EnvironmentType.WindowsStore,
3738
EnvironmentType.Pipenv,
38-
EnvironmentType.Pyenv,
3939
EnvironmentType.Poetry,
4040
EnvironmentType.VirtualEnvWrapper,
4141
EnvironmentType.Venv,

src/client/pythonEnvironments/common/externalDependencies.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as fsapi from 'fs-extra';
55
import * as path from 'path';
66
import { ExecutionResult, IProcessServiceFactory } from '../../common/process/types';
77
import { IPersistentStateFactory } from '../../common/types';
8+
import { chain, iterable } from '../../common/utils/async';
89
import { getOSType, OSType } from '../../common/utils/platform';
910
import { IServiceContainer } from '../../ioc/types';
1011

@@ -73,3 +74,20 @@ export async function resolveSymbolicLink(filepath:string): Promise<string> {
7374
}
7475
return filepath;
7576
}
77+
78+
export async function* getSubDirs(root:string): AsyncIterableIterator<string> {
79+
const dirContents = await fsapi.readdir(root);
80+
const generators = dirContents.map((item) => {
81+
async function* generator() {
82+
const stat = await fsapi.lstat(path.join(root, item));
83+
84+
if (stat.isDirectory()) {
85+
yield item;
86+
}
87+
}
88+
89+
return generator();
90+
});
91+
92+
yield* iterable(chain(generators));
93+
}

0 commit comments

Comments
 (0)