Skip to content

Commit cfe12a7

Browse files
authored
Windows store locator (#14162)
* Initial commit for windows store locator * More tests * Simplify locator * Tweaks * Test fixes * Fix tests
1 parent cc093b0 commit cfe12a7

File tree

7 files changed

+316
-24
lines changed

7 files changed

+316
-24
lines changed

src/client/pythonEnvironments/common/externalDependencies.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,11 @@ export function getGlobalPersistentStore<T>(key: string): IPersistentStore<T> {
5757
set(value: T) { return state.updateValue(value); },
5858
};
5959
}
60+
61+
export async function getFileInfo(filePath: string): Promise<{ctime:number, mtime:number}> {
62+
const data = await fsapi.lstat(filePath);
63+
return {
64+
ctime: data.ctime.getUTCDate(),
65+
mtime: data.mtime.getUTCDate(),
66+
};
67+
}

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

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@
44
import * as fsapi from 'fs-extra';
55
import * as path from 'path';
66
import { traceWarning } from '../../../../common/logger';
7-
import { getEnvironmentVariable } from '../../../../common/utils/platform';
7+
import { Architecture, getEnvironmentVariable } from '../../../../common/utils/platform';
8+
import {
9+
PythonEnvInfo, PythonEnvKind, PythonReleaseLevel, PythonVersion,
10+
} from '../../../base/info';
11+
import { parseVersion } from '../../../base/info/pythonVersion';
12+
import { ILocator, IPythonEnvsIterator } from '../../../base/locator';
13+
import { PythonEnvsWatcher } from '../../../base/watcher';
14+
import { getFileInfo } from '../../../common/externalDependencies';
815
import { isWindowsPythonExe } from '../../../common/windowsUtils';
916

1017
/**
@@ -107,5 +114,51 @@ export async function getWindowsStorePythonExes(): Promise<string[]> {
107114
.filter(isWindowsPythonExe);
108115
}
109116

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.
117+
export class WindowsStoreLocator extends PythonEnvsWatcher implements ILocator {
118+
private readonly kind:PythonEnvKind = PythonEnvKind.WindowsStore;
119+
120+
public iterEnvs(): IPythonEnvsIterator {
121+
const buildEnvInfo = (exe:string) => this.buildEnvInfo(exe);
122+
const iterator = async function* () {
123+
const exes = await getWindowsStorePythonExes();
124+
yield* exes.map(buildEnvInfo);
125+
};
126+
return iterator();
127+
}
128+
129+
public async resolveEnv(env: string | PythonEnvInfo): Promise<PythonEnvInfo | undefined> {
130+
const executablePath = typeof env === 'string' ? env : env.executable.filename;
131+
if (await isWindowsStoreEnvironment(executablePath)) {
132+
return this.buildEnvInfo(executablePath);
133+
}
134+
return undefined;
135+
}
136+
137+
private async buildEnvInfo(exe:string): Promise<PythonEnvInfo> {
138+
let version:PythonVersion;
139+
try {
140+
version = parseVersion(path.basename(exe));
141+
} catch (e) {
142+
version = {
143+
major: 3,
144+
minor: -1,
145+
micro: -1,
146+
release: { level: PythonReleaseLevel.Final, serial: -1 },
147+
sysVersion: undefined,
148+
};
149+
}
150+
return {
151+
name: '',
152+
location: '',
153+
kind: this.kind,
154+
executable: {
155+
filename: exe,
156+
sysPrefix: '',
157+
...(await getFileInfo(exe)),
158+
},
159+
version,
160+
arch: Architecture.x64,
161+
distro: { org: 'Microsoft' },
162+
};
163+
}
164+
}
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.

src/test/pythonEnvironments/discovery/locators/windowsStoreLocator.unit.test.ts

Lines changed: 248 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,259 @@
22
// Licensed under the MIT License.
33

44
import * as assert from 'assert';
5+
import { zip } from 'lodash';
56
import * as path from 'path';
67
import * as sinon from 'sinon';
8+
import { ExecutionResult } from '../../../../client/common/process/types';
79
import * as platformApis from '../../../../client/common/utils/platform';
8-
import * as storeApis from '../../../../client/pythonEnvironments/discovery/locators/services/windowsStoreLocator';
10+
import {
11+
PythonEnvInfo, PythonEnvKind, PythonReleaseLevel, PythonVersion,
12+
} from '../../../../client/pythonEnvironments/base/info';
13+
import { InterpreterInformation } from '../../../../client/pythonEnvironments/base/info/interpreter';
14+
import { parseVersion } from '../../../../client/pythonEnvironments/base/info/pythonVersion';
15+
import * as externalDep from '../../../../client/pythonEnvironments/common/externalDependencies';
16+
import { getWindowsStorePythonExes, WindowsStoreLocator } from '../../../../client/pythonEnvironments/discovery/locators/services/windowsStoreLocator';
17+
import { getEnvs } from '../../base/common';
918
import { TEST_LAYOUT_ROOT } from '../../common/commonTestConstants';
1019

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();
20+
suite('Windows Store', () => {
21+
suite('Utils', () => {
22+
let getEnvVar: sinon.SinonStub;
23+
const testLocalAppData = path.join(TEST_LAYOUT_ROOT, 'storeApps');
24+
const testStoreAppRoot = path.join(testLocalAppData, 'Microsoft', 'WindowsApps');
25+
26+
setup(() => {
27+
getEnvVar = sinon.stub(platformApis, 'getEnvironmentVariable');
28+
getEnvVar.withArgs('LOCALAPPDATA').returns(testLocalAppData);
29+
});
30+
31+
teardown(() => {
32+
getEnvVar.restore();
33+
});
34+
35+
test('Store Python Interpreters', async () => {
36+
const expected = [
37+
path.join(testStoreAppRoot, 'python.exe'),
38+
path.join(testStoreAppRoot, 'python3.7.exe'),
39+
path.join(testStoreAppRoot, 'python3.8.exe'),
40+
path.join(testStoreAppRoot, 'python3.exe'),
41+
];
42+
43+
const actual = await getWindowsStorePythonExes();
44+
assert.deepEqual(actual, expected);
45+
});
2146
});
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);
47+
48+
suite('Locator', () => {
49+
let stubShellExec: sinon.SinonStub;
50+
let getEnvVar: sinon.SinonStub;
51+
52+
const testLocalAppData = path.join(TEST_LAYOUT_ROOT, 'storeApps');
53+
const testStoreAppRoot = path.join(testLocalAppData, 'Microsoft', 'WindowsApps');
54+
const pathToData = new Map<string, {
55+
versionInfo:(string|number)[],
56+
sysPrefix: string,
57+
sysVersion: string,
58+
is64Bit: boolean
59+
}>();
60+
61+
const python383data = {
62+
versionInfo: [3, 8, 3, 'final', 0],
63+
sysPrefix: 'path',
64+
sysVersion: '3.8.3 (tags/v3.8.3:6f8c832, May 13 2020, 22:37:02) [MSC v.1924 64 bit (AMD64)]',
65+
is64Bit: true,
66+
};
67+
68+
const python379data = {
69+
versionInfo: [3, 7, 9, 'final', 0],
70+
sysPrefix: 'path',
71+
sysVersion: '3.7.9 (tags/v3.7.9:13c94747c7, Aug 17 2020, 16:30:00) [MSC v.1900 64 bit (AMD64)]',
72+
is64Bit: true,
73+
};
74+
75+
pathToData.set(path.join(testStoreAppRoot, 'python.exe'), python383data);
76+
pathToData.set(path.join(testStoreAppRoot, 'python3.exe'), python383data);
77+
pathToData.set(path.join(testStoreAppRoot, 'python3.8.exe'), python383data);
78+
pathToData.set(path.join(testStoreAppRoot, 'python3.7.exe'), python379data);
79+
80+
function createExpectedInterpreterInfo(
81+
executable: string,
82+
sysVersion?: string,
83+
sysPrefix?: string,
84+
versionStr?:string,
85+
): InterpreterInformation {
86+
let version:PythonVersion;
87+
try {
88+
version = parseVersion(versionStr ?? path.basename(executable));
89+
if (sysVersion) {
90+
version.sysVersion = sysVersion;
91+
}
92+
} catch (e) {
93+
version = {
94+
major: 3,
95+
minor: -1,
96+
micro: -1,
97+
release: { level: PythonReleaseLevel.Final, serial: -1 },
98+
sysVersion,
99+
};
100+
}
101+
return {
102+
version,
103+
arch: platformApis.Architecture.x64,
104+
executable: {
105+
filename: executable,
106+
sysPrefix: sysPrefix ?? '',
107+
ctime: -1,
108+
mtime: -1,
109+
},
110+
};
111+
}
112+
113+
setup(() => {
114+
stubShellExec = sinon.stub(externalDep, 'shellExecute');
115+
stubShellExec.callsFake((command:string) => {
116+
if (command.indexOf('notpython.exe') > 0) {
117+
return Promise.resolve<ExecutionResult<string>>({ stdout: '' });
118+
}
119+
if (command.indexOf('python3.7.exe') > 0) {
120+
return Promise.resolve<ExecutionResult<string>>({ stdout: JSON.stringify(python379data) });
121+
}
122+
return Promise.resolve<ExecutionResult<string>>({ stdout: JSON.stringify(python383data) });
123+
});
124+
125+
getEnvVar = sinon.stub(platformApis, 'getEnvironmentVariable');
126+
getEnvVar.withArgs('LOCALAPPDATA').returns(testLocalAppData);
127+
});
128+
129+
teardown(() => {
130+
stubShellExec.restore();
131+
getEnvVar.restore();
132+
});
133+
134+
function assertEnvEqual(actual:PythonEnvInfo | undefined, expected: PythonEnvInfo | undefined):void {
135+
assert.notStrictEqual(actual, undefined);
136+
assert.notStrictEqual(expected, undefined);
137+
138+
if (actual) {
139+
// ensure ctime and mtime are greater than -1
140+
assert.ok(actual?.executable.ctime > -1);
141+
assert.ok(actual?.executable.mtime > -1);
142+
143+
// No need to match these, so reset them
144+
actual.executable.ctime = -1;
145+
actual.executable.mtime = -1;
146+
147+
assert.deepStrictEqual(actual, expected);
148+
}
149+
}
150+
151+
test('iterEnvs()', async () => {
152+
const expectedEnvs = [...pathToData.keys()]
153+
.sort((a: string, b: string) => a.localeCompare(b))
154+
.map((k): PythonEnvInfo|undefined => {
155+
const data = pathToData.get(k);
156+
if (data) {
157+
return {
158+
159+
name: '',
160+
location: '',
161+
kind: PythonEnvKind.WindowsStore,
162+
distro: { org: 'Microsoft' },
163+
...createExpectedInterpreterInfo(k),
164+
};
165+
}
166+
return undefined;
167+
});
168+
169+
const locator = new WindowsStoreLocator();
170+
const iterator = locator.iterEnvs();
171+
const actualEnvs = (await getEnvs(iterator))
172+
.sort((a, b) => a.executable.filename.localeCompare(b.executable.filename));
173+
174+
zip(actualEnvs, expectedEnvs).forEach((value) => {
175+
const [actual, expected] = value;
176+
assertEnvEqual(actual, expected);
177+
});
178+
});
179+
180+
test('resolveEnv(string)', async () => {
181+
const python38path = path.join(testStoreAppRoot, 'python3.8.exe');
182+
const expected = {
183+
184+
name: '',
185+
location: '',
186+
kind: PythonEnvKind.WindowsStore,
187+
distro: { org: 'Microsoft' },
188+
...createExpectedInterpreterInfo(python38path),
189+
};
190+
191+
const locator = new WindowsStoreLocator();
192+
const actual = await locator.resolveEnv(python38path);
193+
194+
assertEnvEqual(actual, expected);
195+
});
196+
197+
test('resolveEnv(PythonEnvInfo)', async () => {
198+
const python38path = path.join(testStoreAppRoot, 'python3.8.exe');
199+
const expected = {
200+
201+
name: '',
202+
location: '',
203+
kind: PythonEnvKind.WindowsStore,
204+
distro: { org: 'Microsoft' },
205+
...createExpectedInterpreterInfo(python38path),
206+
};
207+
208+
// Partially filled in env info object
209+
const input:PythonEnvInfo = {
210+
name: '',
211+
location: '',
212+
kind: PythonEnvKind.WindowsStore,
213+
distro: { org: 'Microsoft' },
214+
arch: platformApis.Architecture.x64,
215+
executable: {
216+
filename: python38path,
217+
sysPrefix: '',
218+
ctime: -1,
219+
mtime: -1,
220+
},
221+
version: {
222+
major: 3,
223+
minor: -1,
224+
micro: -1,
225+
release: { level: PythonReleaseLevel.Final, serial: -1 },
226+
},
227+
};
228+
229+
const locator = new WindowsStoreLocator();
230+
const actual = await locator.resolveEnv(input);
231+
232+
assertEnvEqual(actual, expected);
233+
});
234+
test('resolveEnv(string): forbidden path', async () => {
235+
const python38path = path.join(testLocalAppData, 'Program Files', 'WindowsApps', 'python3.8.exe');
236+
const expected = {
237+
238+
name: '',
239+
location: '',
240+
kind: PythonEnvKind.WindowsStore,
241+
distro: { org: 'Microsoft' },
242+
...createExpectedInterpreterInfo(python38path),
243+
};
244+
245+
const locator = new WindowsStoreLocator();
246+
const actual = await locator.resolveEnv(python38path);
247+
248+
assertEnvEqual(actual, expected);
249+
});
250+
test('resolveEnv(string): Non store python', async () => {
251+
// Use a non store root path
252+
const python38path = path.join(testLocalAppData, 'python3.8.exe');
253+
254+
const locator = new WindowsStoreLocator();
255+
const actual = await locator.resolveEnv(python38path);
256+
257+
assert.deepStrictEqual(actual, undefined);
258+
});
32259
});
33260
});

0 commit comments

Comments
 (0)