Skip to content

Commit 40bc21c

Browse files
author
Kartik Raj
authored
Add API to identify pipenv (#13762)
* Add helpers for pipenv * Add error message * oOpS * Reverting regex approach
1 parent b21b2e1 commit 40bc21c

File tree

15 files changed

+554
-6
lines changed

15 files changed

+554
-6
lines changed

src/client/pythonEnvironments/common/environmentIdentifier.ts

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

44
import { isCondaEnvironment } from '../discovery/locators/services/condaLocator';
5+
import { isPipenvEnvironment } from '../discovery/locators/services/pipEnvHelper';
56
import { isVenvEnvironment } from '../discovery/locators/services/venvLocator';
67
import { isVirtualenvEnvironment } from '../discovery/locators/services/virtualenvLocator';
78
import { isWindowsStoreEnvironment } from '../discovery/locators/services/windowsStoreLocator';
@@ -39,6 +40,10 @@ export async function identifyEnvironment(interpreterPath: string): Promise<Envi
3940
return EnvironmentType.WindowsStore;
4041
}
4142

43+
if (await isPipenvEnvironment(interpreterPath)) {
44+
return EnvironmentType.Pipenv;
45+
}
46+
4247
if (await isVenvEnvironment(interpreterPath)) {
4348
return EnvironmentType.Venv;
4449
}

src/client/pythonEnvironments/common/externalDependencies.ts

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

44
import * as fsapi from 'fs-extra';
5+
import * as path from 'path';
56
import { ExecutionResult, IProcessServiceFactory } from '../../common/process/types';
6-
import { createDeferred } from '../../common/utils/async';
7+
import { getOSType, OSType } from '../../common/utils/platform';
78
import { IServiceContainer } from '../../ioc/types';
89

910
let internalServiceContainer: IServiceContainer;
@@ -21,9 +22,18 @@ export async function shellExecute(command: string, timeout: number): Promise<Ex
2122
}
2223

2324
export function pathExists(absPath: string): Promise<boolean> {
24-
const deferred = createDeferred<boolean>();
25-
fsapi.exists(absPath, (result) => {
26-
deferred.resolve(result);
27-
});
28-
return deferred.promise;
25+
return fsapi.pathExists(absPath);
26+
}
27+
28+
export function readFile(filePath: string): Promise<string> {
29+
return fsapi.readFile(filePath, 'utf-8');
30+
}
31+
32+
export function arePathsSame(path1: string, path2: string): boolean {
33+
path1 = path.normalize(path1);
34+
path2 = path.normalize(path2);
35+
if (getOSType() === OSType.Windows) {
36+
return path1.toUpperCase() === path2.toUpperCase();
37+
}
38+
return path1 === path2;
2939
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import * as path from 'path';
5+
import { traceError } from '../../../../common/logger';
6+
import { getEnvironmentVariable } from '../../../../common/utils/platform';
7+
import { arePathsSame, pathExists, readFile } from '../../../common/externalDependencies';
8+
9+
function getSearchHeight() {
10+
// PIPENV_MAX_DEPTH tells pipenv the maximum number of directories to recursively search for
11+
// a Pipfile, defaults to 3: https://pipenv.pypa.io/en/latest/advanced/#pipenv.environments.PIPENV_MAX_DEPTH
12+
const maxDepthStr = getEnvironmentVariable('PIPENV_MAX_DEPTH');
13+
if (maxDepthStr === undefined) {
14+
return 3;
15+
}
16+
const maxDepth = parseInt(maxDepthStr, 10);
17+
// eslint-disable-next-line no-restricted-globals
18+
if (isNaN(maxDepth)) {
19+
traceError(`PIPENV_MAX_DEPTH is incorrectly set. Converting value '${maxDepthStr}' to number results in NaN`);
20+
return 1;
21+
}
22+
return maxDepth;
23+
}
24+
25+
/**
26+
* Returns the path to Pipfile associated with the provided directory.
27+
* @param searchDir the directory to look into
28+
* @param lookIntoParentDirectories set to true if we should also search for Pipfile in parent directory
29+
*/
30+
export async function _getAssociatedPipfile(
31+
searchDir: string,
32+
options: {lookIntoParentDirectories: boolean},
33+
): Promise<string | undefined> {
34+
const pipFileName = getEnvironmentVariable('PIPENV_PIPFILE') || 'Pipfile';
35+
let heightToSearch = options.lookIntoParentDirectories ? getSearchHeight() : 1;
36+
while (heightToSearch > 0 && !arePathsSame(searchDir, path.dirname(searchDir))) {
37+
const pipFile = path.join(searchDir, pipFileName);
38+
// eslint-disable-next-line no-await-in-loop
39+
if (await pathExists(pipFile)) {
40+
return pipFile;
41+
}
42+
searchDir = path.dirname(searchDir);
43+
heightToSearch -= 1;
44+
}
45+
return undefined;
46+
}
47+
48+
/**
49+
* If interpreter path belongs to a pipenv environment which is located inside a project, return associated Pipfile,
50+
* otherwise return `undefined`.
51+
* @param interpreterPath Absolute path to any python interpreter.
52+
*/
53+
async function getPipfileIfLocal(interpreterPath: string): Promise<string | undefined> {
54+
// Local pipenv environments are created by setting PIPENV_VENV_IN_PROJECT to 1, which always names the environment
55+
// folder '.venv': https://pipenv.pypa.io/en/latest/advanced/#pipenv.environments.PIPENV_VENV_IN_PROJECT
56+
// This is the layout we wish to verify.
57+
// project
58+
// |__ Pipfile <--- check if Pipfile exists here
59+
// |__ .venv <--- check if name of the folder is '.venv'
60+
// |__ Scripts/bin
61+
// |__ python <--- interpreterPath
62+
const venvFolder = path.dirname(path.dirname(interpreterPath));
63+
if (path.basename(venvFolder) !== '.venv') {
64+
return undefined;
65+
}
66+
const directoryWhereVenvResides = path.dirname(venvFolder);
67+
return _getAssociatedPipfile(directoryWhereVenvResides, { lookIntoParentDirectories: false });
68+
}
69+
70+
/**
71+
* Returns the project directory for pipenv environments given the environment folder
72+
* @param envFolder Path to the environment folder
73+
*/
74+
async function getProjectDir(envFolder: string): Promise<string | undefined> {
75+
// Global pipenv environments have a .project file with the absolute path to the project
76+
// See https://github.com/pypa/pipenv/blob/v2018.6.25/CHANGELOG.rst#features--improvements
77+
// This is the layout we expect
78+
// <Environment folder>
79+
// |__ .project <--- check if .project exists here
80+
// |__ Scripts/bin
81+
// |__ python <--- interpreterPath
82+
// We get the project by reading the .project file
83+
const dotProjectFile = path.join(envFolder, '.project');
84+
if (!(await pathExists(dotProjectFile))) {
85+
return undefined;
86+
}
87+
const projectDir = await readFile(dotProjectFile);
88+
if (!(await pathExists(projectDir))) {
89+
traceError(`The .project file inside environment folder: ${envFolder} doesn't contain a valid path to the project`);
90+
return undefined;
91+
}
92+
return projectDir;
93+
}
94+
95+
/**
96+
* If interpreter path belongs to a global pipenv environment, return associated Pipfile, otherwise return `undefined`.
97+
* @param interpreterPath Absolute path to any python interpreter.
98+
*/
99+
async function getPipfileIfGlobal(interpreterPath: string): Promise<string | undefined> {
100+
const envFolder = path.dirname(path.dirname(interpreterPath));
101+
const projectDir = await getProjectDir(envFolder);
102+
if (projectDir === undefined) {
103+
return undefined;
104+
}
105+
106+
// This is the layout we expect to see.
107+
// project
108+
// |__ Pipfile <--- check if Pipfile exists here and return it
109+
// The name of the project (directory where Pipfile resides) is used as a prefix in the environment folder
110+
const envFolderName = path.basename(envFolder);
111+
if (!envFolderName.startsWith(`${path.basename(projectDir)}-`)) {
112+
return undefined;
113+
}
114+
115+
return _getAssociatedPipfile(projectDir, { lookIntoParentDirectories: false });
116+
}
117+
118+
/**
119+
* Checks if the given interpreter path belongs to a pipenv environment, by locating the Pipfile which was used to
120+
* create the environment.
121+
* @param interpreterPath: Absolute path to any python interpreter.
122+
*/
123+
export async function isPipenvEnvironment(interpreterPath: string): Promise<boolean> {
124+
if (await getPipfileIfLocal(interpreterPath)) {
125+
return true;
126+
}
127+
if (await getPipfileIfGlobal(interpreterPath)) {
128+
return true;
129+
}
130+
return false;
131+
}
132+
133+
/**
134+
* Returns true if interpreter path belongs to a global pipenv environment which is associated with a particular folder,
135+
* false otherwise.
136+
* @param interpreterPath Absolute path to any python interpreter.
137+
*/
138+
export async function isPipenvEnvironmentRelatedToFolder(interpreterPath: string, folder: string): Promise<boolean> {
139+
const pipFileAssociatedWithEnvironment = await getPipfileIfGlobal(interpreterPath);
140+
if (!pipFileAssociatedWithEnvironment) {
141+
return false;
142+
}
143+
144+
// PIPENV_NO_INHERIT is used to tell pipenv not to look for Pipfile in parent directories
145+
// https://pipenv.pypa.io/en/latest/advanced/#pipenv.environments.PIPENV_NO_INHERIT
146+
const lookIntoParentDirectories = (getEnvironmentVariable('PIPENV_NO_INHERIT') === undefined);
147+
const pipFileAssociatedWithFolder = await _getAssociatedPipfile(folder, { lookIntoParentDirectories });
148+
if (!pipFileAssociatedWithFolder) {
149+
return false;
150+
}
151+
return arePathsSame(pipFileAssociatedWithEnvironment, pipFileAssociatedWithFolder);
152+
}

src/test/pythonEnvironments/common/environmentIdentifier.unit.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as path from 'path';
66
import * as sinon from 'sinon';
77
import * as platformApis from '../../../client/common/utils/platform';
88
import { identifyEnvironment } from '../../../client/pythonEnvironments/common/environmentIdentifier';
9+
import * as externalDependencies from '../../../client/pythonEnvironments/common/externalDependencies';
910
import { EnvironmentType } from '../../../client/pythonEnvironments/info';
1011
import { TEST_LAYOUT_ROOT } from './commonTestConstants';
1112

@@ -23,6 +24,40 @@ suite('Environment Identifier', () => {
2324
});
2425
});
2526

27+
suite('Pipenv', () => {
28+
let getEnvVar: sinon.SinonStub;
29+
let readFile: sinon.SinonStub;
30+
setup(() => {
31+
getEnvVar = sinon.stub(platformApis, 'getEnvironmentVariable');
32+
readFile = sinon.stub(externalDependencies, 'readFile');
33+
});
34+
35+
teardown(() => {
36+
readFile.restore();
37+
getEnvVar.restore();
38+
});
39+
40+
test('Path to a global pipenv environment', async () => {
41+
const expectedDotProjectFile = path.join(TEST_LAYOUT_ROOT, 'pipenv', 'globalEnvironments', 'project2-vnNIWe9P', '.project');
42+
const expectedProjectFile = path.join(TEST_LAYOUT_ROOT, 'pipenv', 'project2');
43+
readFile.withArgs(expectedDotProjectFile).resolves(expectedProjectFile);
44+
const interpreterPath: string = path.join(TEST_LAYOUT_ROOT, 'pipenv', 'globalEnvironments', 'project2-vnNIWe9P', 'bin', 'python');
45+
46+
const envType: EnvironmentType = await identifyEnvironment(interpreterPath);
47+
48+
assert.equal(envType, EnvironmentType.Pipenv);
49+
});
50+
51+
test('Path to a local pipenv environment with a custom Pipfile name', async () => {
52+
getEnvVar.withArgs('PIPENV_PIPFILE').returns('CustomPipfileName');
53+
const interpreterPath: string = path.join(TEST_LAYOUT_ROOT, 'pipenv', 'project1', '.venv', 'Scripts', 'python.exe');
54+
55+
const envType: EnvironmentType = await identifyEnvironment(interpreterPath);
56+
57+
assert.equal(envType, EnvironmentType.Pipenv);
58+
});
59+
});
60+
2661
suite('Windows Store', () => {
2762
let getEnvVar: sinon.SinonStub;
2863
const fakeLocalAppDataPath = path.join(TEST_LAYOUT_ROOT, 'storeApps');
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Absolute path to \src\test\pythonEnvironments\common\envlayouts\pipenv\project2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Not a real python binary
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Absolute path to \src\test\pythonEnvironments\common\envlayouts\pipenv\project3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Not real python exe
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Not real python exe
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
[[source]]
2+
name = "pypi"
3+
url = "https://pypi.org/simple"
4+
verify_ssl = true
5+
6+
[dev-packages]
7+
8+
[packages]
9+
10+
[requires]
11+
python_version = "3.7"
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
[[source]]
2+
name = "pypi"
3+
url = "https://pypi.org/simple"
4+
verify_ssl = true
5+
6+
[dev-packages]
7+
8+
[packages]
9+
10+
[requires]
11+
python_version = "3.7"
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
[[source]]
2+
name = "pypi"
3+
url = "https://pypi.org/simple"
4+
verify_ssl = true
5+
6+
[dev-packages]
7+
8+
[packages]
9+
10+
[requires]
11+
python_version = "3.7"

src/test/pythonEnvironments/common/envlayouts/pipenv/project3/parent/child/folder/dummyFile

Whitespace-only changes.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import * as assert from 'assert';
2+
import * as path from 'path';
3+
import * as sinon from 'sinon';
4+
import * as platformApis from '../../../../client/common/utils/platform';
5+
import * as externalDependencies from '../../../../client/pythonEnvironments/common/externalDependencies';
6+
import { isPipenvEnvironmentRelatedToFolder } from '../../../../client/pythonEnvironments/discovery/locators/services/pipEnvHelper';
7+
import { TEST_LAYOUT_ROOT } from '../../common/commonTestConstants';
8+
9+
suite('Pipenv utils', () => {
10+
let readFile: sinon.SinonStub;
11+
let getEnvVar: sinon.SinonStub;
12+
setup(() => {
13+
getEnvVar = sinon.stub(platformApis, 'getEnvironmentVariable');
14+
readFile = sinon.stub(externalDependencies, 'readFile');
15+
});
16+
17+
teardown(() => {
18+
readFile.restore();
19+
getEnvVar.restore();
20+
});
21+
22+
test('Global pipenv environment is associated with a project whose Pipfile lies at 3 levels above the project', async () => {
23+
getEnvVar.withArgs('PIPENV_MAX_DEPTH').returns('5');
24+
const expectedDotProjectFile = path.join(TEST_LAYOUT_ROOT, 'pipenv', 'globalEnvironments', 'project3-2s1eXEJ2', '.project');
25+
const project = path.join(TEST_LAYOUT_ROOT, 'pipenv', 'project3');
26+
readFile.withArgs(expectedDotProjectFile).resolves(project);
27+
const interpreterPath: string = path.join(TEST_LAYOUT_ROOT, 'pipenv', 'globalEnvironments', 'project3-2s1eXEJ2', 'Scripts', 'python.exe');
28+
const folder = path.join(project, 'parent', 'child', 'folder');
29+
30+
const isRelated = await isPipenvEnvironmentRelatedToFolder(interpreterPath, folder);
31+
32+
assert.equal(isRelated, true);
33+
});
34+
});

0 commit comments

Comments
 (0)