Skip to content

Commit 869ec10

Browse files
authored
Identify conda and windows store environments from given interpreter path (#13589)
* Initial commit * Implement Windows store identifier * Fix test errors. * Add more notes on windows store identifier. * minor tweaks. * Address comments. * Clean up * Missed suggestions.
1 parent a9f628d commit 869ec10

File tree

8 files changed

+293
-1
lines changed

8 files changed

+293
-1
lines changed

src/client/common/utils/platform.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
'use strict';
55

6+
import { EnvironmentVariables } from '../variables/types';
7+
68
export enum Architecture {
79
Unknown = 1,
810
x86 = 2,
@@ -27,3 +29,19 @@ export function getOSType(platform: string = process.platform): OSType {
2729
return OSType.Unknown;
2830
}
2931
}
32+
33+
export function getEnvironmentVariable(key: string): string | undefined {
34+
// tslint:disable-next-line: no-any
35+
return ((process.env as any) as EnvironmentVariables)[key];
36+
}
37+
38+
export function getPathEnvironmentVariable(): string | undefined {
39+
return getEnvironmentVariable('Path') || getEnvironmentVariable('PATH');
40+
}
41+
42+
export function getUserHomeDir(): string | undefined {
43+
if (getOSType() === OSType.Windows) {
44+
return getEnvironmentVariable('USERPROFILE');
45+
}
46+
return getEnvironmentVariable('HOME') || getEnvironmentVariable('HOMEPATH');
47+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
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 { traceWarning } from '../../common/logger';
7+
import { createDeferred } from '../../common/utils/async';
8+
import { getEnvironmentVariable } from '../../common/utils/platform';
9+
import { EnvironmentType } from '../info';
10+
11+
function pathExists(absPath: string): Promise<boolean> {
12+
const deferred = createDeferred<boolean>();
13+
fsapi.exists(absPath, (result) => {
14+
deferred.resolve(result);
15+
});
16+
return deferred.promise;
17+
}
18+
19+
/**
20+
* Checks if the given interpreter path belongs to a conda environment. Using
21+
* known folder layout, and presence of 'conda-meta' directory.
22+
* @param {string} interpreterPath: Absolute path to any python interpreter.
23+
*
24+
* Remarks: This is what we will use to begin with. Another approach we can take
25+
* here is to parse ~/.conda/environments.txt. This file will have list of conda
26+
* environments. We can compare the interpreter path against the paths in that file.
27+
* We don't want to rely on this file because it is an implementation detail of
28+
* conda. If it turns out that the layout based identification is not sufficient
29+
* that is the next alternative that is cheap.
30+
*
31+
* sample content of the ~/.conda/environments.txt:
32+
* C:\envs\\myenv
33+
* C:\ProgramData\Miniconda3
34+
*
35+
* Yet another approach is to use `conda env list --json` and compare the returned env
36+
* list to see if the given interpreter path belongs to any of the returned environments.
37+
* This approach is heavy, and involves running a binary. For now we decided not to
38+
* take this approach, since it does not look like we need it.
39+
*
40+
* sample output from `conda env list --json`:
41+
* conda env list --json
42+
* {
43+
* "envs": [
44+
* "C:\\envs\\myenv",
45+
* "C:\\ProgramData\\Miniconda3"
46+
* ]
47+
* }
48+
*/
49+
async function isCondaEnvironment(interpreterPath: string): Promise<boolean> {
50+
const condaMetaDir = 'conda-meta';
51+
52+
// Check if the conda-meta directory is in the same directory as the interpreter.
53+
// This layout is common in Windows.
54+
// env
55+
// |__ conda-meta <--- check if this directory exists
56+
// |__ python.exe <--- interpreterPath
57+
const condaEnvDir1 = path.join(path.dirname(interpreterPath), condaMetaDir);
58+
59+
// Check if the conda-meta directory is in the parent directory relative to the interpreter.
60+
// This layout is common on linux/Mac.
61+
// env
62+
// |__ conda-meta <--- check if this directory exists
63+
// |__ bin
64+
// |__ python <--- interpreterPath
65+
const condaEnvDir2 = path.join(path.dirname(path.dirname(interpreterPath)), condaMetaDir);
66+
67+
return [await pathExists(condaEnvDir1), await pathExists(condaEnvDir2)].includes(true);
68+
}
69+
70+
/**
71+
* Checks if the given interpreter belongs to Windows Store Python environment.
72+
* @param interpreterPath: Absolute path to any python interpreter.
73+
*
74+
* Remarks:
75+
* 1. Checking if the path includes 'Microsoft\WindowsApps`, `Program Files\WindowsApps`, is
76+
* NOT enough. In WSL, /mnt/c/users/user/AppData/Local/Microsoft/WindowsApps is available as a search
77+
* path. It is possible to get a false positive for that path. So the comparison should check if the
78+
* absolute path to 'WindowsApps' directory is present in the given interpreter path. The WSL path to
79+
* 'WindowsApps' is not a valid path to access, Windows Store Python.
80+
*
81+
* 2. 'startsWith' comparison may not be right, user can provide '\\?\C:\users\' style long paths in windows.
82+
*
83+
* 3. A limitation of the checks here is that they don't handle 8.3 style windows paths.
84+
* For example,
85+
* C:\Users\USER\AppData\Local\MICROS~1\WINDOW~1\PYTHON~2.EXE
86+
* is the shortened form of
87+
* C:\Users\USER\AppData\Local\Microsoft\WindowsApps\python3.7.exe
88+
*
89+
* The correct way to compare these would be to always convert given paths to long path (or to short path).
90+
* For either approach to work correctly you need actual file to exist, and accessible from the user's
91+
* account.
92+
*
93+
* To convert to short path without using N-API in node would be to use this command. This is very expensive:
94+
* > cmd /c for %A in ("C:\Users\USER\AppData\Local\Microsoft\WindowsApps\python3.7.exe") do @echo %~sA
95+
* The above command will print out this:
96+
* C:\Users\USER\AppData\Local\MICROS~1\WINDOW~1\PYTHON~2.EXE
97+
*
98+
* If we go down the N-API route, use node-ffi and either call GetShortPathNameW or GetLongPathNameW from,
99+
* Kernel32 to convert between the two path variants.
100+
*
101+
*/
102+
async function isWindowsStoreEnvironment(interpreterPath: string): Promise<boolean> {
103+
const pythonPathToCompare = path.normalize(interpreterPath).toUpperCase();
104+
const localAppDataStorePath = path
105+
.join(getEnvironmentVariable('LOCALAPPDATA') || '', 'Microsoft', 'WindowsApps')
106+
.normalize()
107+
.toUpperCase();
108+
if (pythonPathToCompare.includes(localAppDataStorePath)) {
109+
return true;
110+
}
111+
112+
// Program Files store path is a forbidden path. Only admins and system has access this path.
113+
// We should never have to look at this path or even execute python from this path.
114+
const programFilesStorePath = path
115+
.join(getEnvironmentVariable('ProgramFiles') || 'Program Files', 'WindowsApps')
116+
.normalize()
117+
.toUpperCase();
118+
if (pythonPathToCompare.includes(programFilesStorePath)) {
119+
traceWarning('isWindowsStoreEnvironment called with Program Files store path.');
120+
return true;
121+
}
122+
return false;
123+
}
124+
125+
/**
126+
* Returns environment type.
127+
* @param {string} interpreterPath : Absolute path to the python interpreter binary.
128+
* @returns {EnvironmentType}
129+
*
130+
* Remarks: This is the order of detection based on how the various distributions and tools
131+
* configure the environment, and the fall back for identification.
132+
* Top level we have the following environment types, since they leave a unique signature
133+
* in the environment or * use a unique path for the environments they create.
134+
* 1. Conda
135+
* 2. Windows Store
136+
* 3. PipEnv
137+
* 4. Pyenv
138+
* 5. Poetry
139+
*
140+
* Next level we have the following virtual environment tools. The are here because they
141+
* are consumed by the tools above, and can also be used independently.
142+
* 1. venv
143+
* 2. virtualenvwrapper
144+
* 3. virtualenv
145+
*
146+
* Last category is globally installed python, or system python.
147+
*/
148+
export async function identifyEnvironment(interpreterPath: string): Promise<EnvironmentType> {
149+
if (await isCondaEnvironment(interpreterPath)) {
150+
return EnvironmentType.Conda;
151+
}
152+
153+
if (await isWindowsStoreEnvironment(interpreterPath)) {
154+
return EnvironmentType.WindowsStore;
155+
}
156+
157+
// additional identifiers go here
158+
159+
return EnvironmentType.Unknown;
160+
}

src/client/pythonEnvironments/discovery/locators/helpers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export async function lookForInterpretersInDirectory(pathToCheck: string, _: IFi
2121
.map((filename) => path.join(pathToCheck, filename))
2222
.filter((fileName) => CheckPythonInterpreterRegEx.test(path.basename(fileName)));
2323
} catch (err) {
24-
traceError('Python Extension (lookForInterpretersInDirectory.fs.listdir):', err);
24+
traceError('Python Extension (lookForInterpretersInDirectory.fs.readdir):', err);
2525
return [] as string[];
2626
}
2727
}
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 assert from 'assert';
5+
import * as path from 'path';
6+
import * as sinon from 'sinon';
7+
import * as platformApis from '../../../client/common/utils/platform';
8+
import { identifyEnvironment } from '../../../client/pythonEnvironments/common/environmentIdentifier';
9+
import { EnvironmentType } from '../../../client/pythonEnvironments/info';
10+
11+
suite('Environment Identifier', () => {
12+
const testLayoutsRoot = path.join(
13+
__dirname,
14+
'..',
15+
'..',
16+
'..',
17+
'..',
18+
'src',
19+
'test',
20+
'pythonEnvironments',
21+
'common',
22+
'envlayouts'
23+
);
24+
suite('Conda', () => {
25+
test('Conda layout with conda-meta and python binary in the same directory', async () => {
26+
const interpreterPath: string = path.join(testLayoutsRoot, 'conda1', 'python.exe');
27+
const envType: EnvironmentType = await identifyEnvironment(interpreterPath);
28+
assert.deepEqual(envType, EnvironmentType.Conda);
29+
});
30+
test('Conda layout with conda-meta and python binary in a sub directory', async () => {
31+
const interpreterPath: string = path.join(testLayoutsRoot, 'conda2', 'bin', 'python');
32+
const envType: EnvironmentType = await identifyEnvironment(interpreterPath);
33+
assert.deepEqual(envType, EnvironmentType.Conda);
34+
});
35+
});
36+
37+
suite('Windows Store', () => {
38+
let getEnvVar: sinon.SinonStub;
39+
const fakeLocalAppDataPath = 'X:\\users\\user\\AppData\\Local';
40+
const fakeProgramFilesPath = 'X:\\Program Files';
41+
const executable = ['python.exe', 'python3.exe', 'python3.8.exe'];
42+
suiteSetup(() => {
43+
getEnvVar = sinon.stub(platformApis, 'getEnvironmentVariable');
44+
getEnvVar.withArgs('LOCALAPPDATA').returns(fakeLocalAppDataPath);
45+
getEnvVar.withArgs('ProgramFiles').returns(fakeProgramFilesPath);
46+
});
47+
suiteTeardown(() => {
48+
getEnvVar.restore();
49+
});
50+
executable.forEach((exe) => {
51+
test(`Path to local app data windows store interpreter (${exe})`, async () => {
52+
const interpreterPath = path.join(fakeLocalAppDataPath, 'Microsoft', 'WindowsApps', exe);
53+
const envType: EnvironmentType = await identifyEnvironment(interpreterPath);
54+
assert.deepEqual(envType, EnvironmentType.WindowsStore);
55+
});
56+
test(`Path to local app data windows store interpreter app sub-directory (${exe})`, async () => {
57+
const interpreterPath = path.join(
58+
fakeLocalAppDataPath,
59+
'Microsoft',
60+
'WindowsApps',
61+
'PythonSoftwareFoundation.Python.3.8_qbz5n2kfra8p0',
62+
exe
63+
);
64+
const envType: EnvironmentType = await identifyEnvironment(interpreterPath);
65+
assert.deepEqual(envType, EnvironmentType.WindowsStore);
66+
});
67+
test(`Path to program files windows store interpreter app sub-directory (${exe})`, async () => {
68+
const interpreterPath = path.join(
69+
fakeProgramFilesPath,
70+
'WindowsApps',
71+
'PythonSoftwareFoundation.Python.3.8_qbz5n2kfra8p0',
72+
exe
73+
);
74+
const envType: EnvironmentType = await identifyEnvironment(interpreterPath);
75+
assert.deepEqual(envType, EnvironmentType.WindowsStore);
76+
});
77+
test(`Local app data not set (${exe})`, async () => {
78+
getEnvVar.withArgs('LOCALAPPDATA').returns(undefined);
79+
const interpreterPath = path.join(fakeLocalAppDataPath, 'Microsoft', 'WindowsApps', exe);
80+
const envType: EnvironmentType = await identifyEnvironment(interpreterPath);
81+
assert.deepEqual(envType, EnvironmentType.WindowsStore);
82+
});
83+
test(`Program files app data not set (${exe})`, async () => {
84+
getEnvVar.withArgs('ProgramFiles').returns(undefined);
85+
const interpreterPath = path.join(
86+
fakeProgramFilesPath,
87+
'WindowsApps',
88+
'PythonSoftwareFoundation.Python.3.8_qbz5n2kfra8p0',
89+
exe
90+
);
91+
const envType: EnvironmentType = await identifyEnvironment(interpreterPath);
92+
assert.deepEqual(envType, EnvironmentType.WindowsStore);
93+
});
94+
test(`Path using forward slashes (${exe})`, async () => {
95+
const interpreterPath = path
96+
.join(fakeLocalAppDataPath, 'Microsoft', 'WindowsApps', exe)
97+
.replace('\\', '/');
98+
const envType: EnvironmentType = await identifyEnvironment(interpreterPath);
99+
assert.deepEqual(envType, EnvironmentType.WindowsStore);
100+
});
101+
test(`Path using long path style slashes (${exe})`, async () => {
102+
const interpreterPath = path
103+
.join(fakeLocalAppDataPath, 'Microsoft', 'WindowsApps', exe)
104+
.replace('\\', '/');
105+
const envType: EnvironmentType = await identifyEnvironment(`\\\\?\\${interpreterPath}`);
106+
assert.deepEqual(envType, EnvironmentType.WindowsStore);
107+
});
108+
});
109+
});
110+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Usually contains command that was used to create or update the conda environment with time stamps.
Lines changed: 1 addition & 0 deletions
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 a real python binary
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Usually contains command that was used to create or update the conda environment with time stamps.

0 commit comments

Comments
 (0)