Skip to content

Commit f41bef9

Browse files
authored
Add APIs needed by windows store locator (#13740)
* Initial commit * Add tests
1 parent fb797a3 commit f41bef9

File tree

18 files changed

+224
-71
lines changed

18 files changed

+224
-71
lines changed

src/client/pythonEnvironments/common/environmentIdentifier.ts

Lines changed: 1 addition & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,8 @@
33

44
import * as fsapi from 'fs-extra';
55
import * as path from 'path';
6-
import { traceWarning } from '../../common/logger';
76
import { createDeferred } from '../../common/utils/async';
8-
import { getEnvironmentVariable } from '../../common/utils/platform';
7+
import { isWindowsStoreEnvironment } from '../discovery/locators/services/windowsStoreLocator';
98
import { EnvironmentType } from '../info';
109

1110
function pathExists(absPath: string): Promise<boolean> {
@@ -67,61 +66,6 @@ async function isCondaEnvironment(interpreterPath: string): Promise<boolean> {
6766
return [await pathExists(condaEnvDir1), await pathExists(condaEnvDir2)].includes(true);
6867
}
6968

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-
12569
/**
12670
* Returns environment type.
12771
* @param {string} interpreterPath : Absolute path to the python interpreter binary.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import * as path from 'path';
5+
6+
/**
7+
* Checks if a given path ends with python*.exe
8+
* @param {string} interpreterPath : Path to python interpreter.
9+
* @returns {boolean} : Returns true if the path matches pattern for windows python executable.
10+
*/
11+
export function isWindowsPythonExe(interpreterPath:string): boolean {
12+
/**
13+
* This Reg-ex matches following file names:
14+
* python.exe
15+
* python3.exe
16+
* python38.exe
17+
* python3.8.exe
18+
*/
19+
const windowsPythonExes = /^python(\d+(.\d+)?)?\.exe$/;
20+
21+
return windowsPythonExes.test(path.basename(interpreterPath));
22+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
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 { getEnvironmentVariable } from '../../../../common/utils/platform';
8+
import { isWindowsPythonExe } from '../../../common/windowsUtils';
9+
10+
/**
11+
* Gets path to the Windows Apps directory.
12+
* @returns {string} : Returns path to the Windows Apps directory under
13+
* `%LOCALAPPDATA%/Microsoft/WindowsApps`.
14+
*/
15+
export function getWindowsStoreAppsRoot(): string {
16+
const localAppData = getEnvironmentVariable('LOCALAPPDATA') || '';
17+
return path.join(localAppData, 'Microsoft', 'WindowsApps');
18+
}
19+
20+
/**
21+
* Checks if a given path is under the forbidden windows store directory.
22+
* @param {string} interpreterPath : Absolute path to the python interpreter.
23+
* @returns {boolean} : Returns true if `interpreterPath` is under
24+
* `%ProgramFiles%/WindowsApps`.
25+
*/
26+
export function isForbiddenStorePath(interpreterPath:string):boolean {
27+
const programFilesStorePath = path
28+
.join(getEnvironmentVariable('ProgramFiles') || 'Program Files', 'WindowsApps')
29+
.normalize()
30+
.toUpperCase();
31+
return path.normalize(interpreterPath).toUpperCase().includes(programFilesStorePath);
32+
}
33+
34+
/**
35+
* Checks if the given interpreter belongs to Windows Store Python environment.
36+
* @param interpreterPath: Absolute path to any python interpreter.
37+
*
38+
* Remarks:
39+
* 1. Checking if the path includes `Microsoft\WindowsApps`, `Program Files\WindowsApps`, is
40+
* NOT enough. In WSL, `/mnt/c/users/user/AppData/Local/Microsoft/WindowsApps` is available as a search
41+
* path. It is possible to get a false positive for that path. So the comparison should check if the
42+
* absolute path to 'WindowsApps' directory is present in the given interpreter path. The WSL path to
43+
* 'WindowsApps' is not a valid path to access, Windows Store Python.
44+
*
45+
* 2. 'startsWith' comparison may not be right, user can provide '\\?\C:\users\' style long paths in windows.
46+
*
47+
* 3. A limitation of the checks here is that they don't handle 8.3 style windows paths.
48+
* For example,
49+
* `C:\Users\USER\AppData\Local\MICROS~1\WINDOW~1\PYTHON~2.EXE`
50+
* is the shortened form of
51+
* `C:\Users\USER\AppData\Local\Microsoft\WindowsApps\python3.7.exe`
52+
*
53+
* The correct way to compare these would be to always convert given paths to long path (or to short path).
54+
* For either approach to work correctly you need actual file to exist, and accessible from the user's
55+
* account.
56+
*
57+
* To convert to short path without using N-API in node would be to use this command. This is very expensive:
58+
* `> cmd /c for %A in ("C:\Users\USER\AppData\Local\Microsoft\WindowsApps\python3.7.exe") do @echo %~sA`
59+
* The above command will print out this:
60+
* `C:\Users\USER\AppData\Local\MICROS~1\WINDOW~1\PYTHON~2.EXE`
61+
*
62+
* If we go down the N-API route, use node-ffi and either call GetShortPathNameW or GetLongPathNameW from,
63+
* Kernel32 to convert between the two path variants.
64+
*
65+
*/
66+
export async function isWindowsStoreEnvironment(interpreterPath: string): Promise<boolean> {
67+
const pythonPathToCompare = path.normalize(interpreterPath).toUpperCase();
68+
const localAppDataStorePath = path
69+
.normalize(getWindowsStoreAppsRoot())
70+
.toUpperCase();
71+
if (pythonPathToCompare.includes(localAppDataStorePath)) {
72+
return true;
73+
}
74+
75+
// Program Files store path is a forbidden path. Only admins and system has access this path.
76+
// We should never have to look at this path or even execute python from this path.
77+
if (isForbiddenStorePath(pythonPathToCompare)) {
78+
traceWarning('isWindowsStoreEnvironment called with Program Files store path.');
79+
return true;
80+
}
81+
return false;
82+
}
83+
84+
/**
85+
* Gets paths to the Python executable under Windows Store apps.
86+
* @returns: Returns python*.exe for the windows store app root directory.
87+
*
88+
* Remarks: We don't need to find the path to the interpreter under the specific application
89+
* directory. Such as:
90+
* `%LOCALAPPDATA%/Microsoft/WindowsApps/PythonSoftwareFoundation.Python.3.7_qbz5n2kfra8p0`
91+
* The same python executable is also available at:
92+
* `%LOCALAPPDATA%/Microsoft/WindowsApps`
93+
* It would be a duplicate.
94+
*
95+
* All python executable under `%LOCALAPPDATA%/Microsoft/WindowsApps` or the sub-directories
96+
* are 'reparse points' that point to the real executable at `%PROGRAMFILES%/WindowsApps`.
97+
* However, that directory is off limits to users. So no need to populate interpreters from
98+
* that location.
99+
*/
100+
export async function getWindowsStorePythonExes(): Promise<string[]> {
101+
const windowsAppsRoot = getWindowsStoreAppsRoot();
102+
103+
// Collect python*.exe directly under %LOCALAPPDATA%/Microsoft/WindowsApps
104+
const files = await fsapi.readdir(windowsAppsRoot);
105+
return files
106+
.map((filename:string) => path.join(windowsAppsRoot, filename))
107+
.filter(isWindowsPythonExe);
108+
}
109+
110+
// tslint:disable-next-line: no-suspicious-comment
111+
// TODO: The above APIs will be consumed by the Windows Store locator class when we have it.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import * as path from 'path';
2+
3+
export const TEST_LAYOUT_ROOT = path.join(
4+
__dirname,
5+
'..',
6+
'..',
7+
'..',
8+
'..',
9+
'src',
10+
'test',
11+
'pythonEnvironments',
12+
'common',
13+
'envlayouts',
14+
);

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

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,28 +7,17 @@ import * as sinon from 'sinon';
77
import * as platformApis from '../../../client/common/utils/platform';
88
import { identifyEnvironment } from '../../../client/pythonEnvironments/common/environmentIdentifier';
99
import { EnvironmentType } from '../../../client/pythonEnvironments/info';
10+
import { TEST_LAYOUT_ROOT } from './commonTestConstants';
1011

1112
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-
);
2413
suite('Conda', () => {
2514
test('Conda layout with conda-meta and python binary in the same directory', async () => {
26-
const interpreterPath: string = path.join(testLayoutsRoot, 'conda1', 'python.exe');
15+
const interpreterPath: string = path.join(TEST_LAYOUT_ROOT, 'conda1', 'python.exe');
2716
const envType: EnvironmentType = await identifyEnvironment(interpreterPath);
2817
assert.deepEqual(envType, EnvironmentType.Conda);
2918
});
3019
test('Conda layout with conda-meta and python binary in a sub directory', async () => {
31-
const interpreterPath: string = path.join(testLayoutsRoot, 'conda2', 'bin', 'python');
20+
const interpreterPath: string = path.join(TEST_LAYOUT_ROOT, 'conda2', 'bin', 'python');
3221
const envType: EnvironmentType = await identifyEnvironment(interpreterPath);
3322
assert.deepEqual(envType, EnvironmentType.Conda);
3423
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Not a real exe.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Not a real exe.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Not a real exe.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Not a real exe.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Not a real exe.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Not a real exe.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Not a real exe.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Not a real exe.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Not a real exe.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Not a real exe.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Not a real exe.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import * as assert from 'assert';
5+
import { isWindowsPythonExe } from '../../../client/pythonEnvironments/common/windowsUtils';
6+
7+
suite('Windows Utils tests', () => {
8+
const testParams = [
9+
{ path: 'python.exe', expected: true },
10+
{ path: 'python3.exe', expected: true },
11+
{ path: 'python38.exe', expected: true },
12+
{ path: 'python3.8.exe', expected: true },
13+
{ path: 'python', expected: false },
14+
{ path: 'python3', expected: false },
15+
{ path: 'python38', expected: false },
16+
{ path: 'python3.8', expected: false },
17+
{ path: 'idle.exe', expected: false },
18+
{ path: 'pip.exe', expected: false },
19+
{ path: 'python.dll', expected: false },
20+
{ path: 'python3.dll', expected: false },
21+
{ path: 'python3.8.dll', expected: false },
22+
];
23+
24+
testParams.forEach((testParam) => {
25+
test(`Python executable check ${testParam.expected ? 'should match' : 'should not match'} this path: ${testParam.path}`, () => {
26+
assert.deepEqual(isWindowsPythonExe(testParam.path), testParam.expected);
27+
});
28+
});
29+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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 * as storeApis from '../../../../client/pythonEnvironments/discovery/locators/services/windowsStoreLocator';
9+
import { TEST_LAYOUT_ROOT } from '../../common/commonTestConstants';
10+
11+
suite('Windows Store Utils', () => {
12+
let getEnvVar: sinon.SinonStub;
13+
const testLocalAppData = path.join(TEST_LAYOUT_ROOT, 'storeApps');
14+
const testStoreAppRoot = path.join(testLocalAppData, 'Microsoft', 'WindowsApps');
15+
setup(() => {
16+
getEnvVar = sinon.stub(platformApis, 'getEnvironmentVariable');
17+
getEnvVar.withArgs('LOCALAPPDATA').returns(testLocalAppData);
18+
});
19+
teardown(() => {
20+
getEnvVar.restore();
21+
});
22+
test('Store Python Interpreters', async () => {
23+
const expected = [
24+
path.join(testStoreAppRoot, 'python.exe'),
25+
path.join(testStoreAppRoot, 'python3.7.exe'),
26+
path.join(testStoreAppRoot, 'python3.8.exe'),
27+
path.join(testStoreAppRoot, 'python3.exe'),
28+
];
29+
30+
const actual = await storeApis.getWindowsStorePythonExes();
31+
assert.deepEqual(actual, expected);
32+
});
33+
});

0 commit comments

Comments
 (0)