Skip to content

Commit f752706

Browse files
author
Kartik Raj
authored
Added Windows store file system watcher (#14577)
* Added Windows store file system watcher * Use glob pattern matcher instead of regex * Code reviews * Code reviews * Remove picomatch and use already existing minimatch * Code reviews * Correct python exe glob for python3.10 * Add a comment stating why we only capture python3.*.exes * Refactor locator setup out of tests * Correct glob * Oops * Cleanly indent
1 parent a678fbe commit f752706

File tree

3 files changed

+206
-23
lines changed

3 files changed

+206
-23
lines changed

src/client/pythonEnvironments/common/pythonBinariesWatcher.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,23 @@
33

44
'use strict';
55

6+
import * as minimatch from 'minimatch';
7+
import * as path from 'path';
68
import { FileChangeType, watchLocationForPattern } from '../../common/platform/fileSystemWatcher';
79
import { getOSType, OSType } from '../../common/utils/platform';
810

911
const [executable, binName] = getOSType() === OSType.Windows ? ['python.exe', 'Scripts'] : ['python', 'bin'];
10-
const patterns = [executable, `*/${executable}`, `*/${binName}/${executable}`];
1112

1213
export function watchLocationForPythonBinaries(
1314
baseDir: string,
1415
callback: (type: FileChangeType, absPath: string) => void,
16+
executableGlob: string = executable,
1517
): void {
18+
const patterns = [executableGlob, `*/${executableGlob}`, `*/${binName}/${executableGlob}`];
1619
for (const pattern of patterns) {
1720
watchLocationForPattern(baseDir, pattern, (type: FileChangeType, e: string) => {
18-
if (!e.endsWith(executable)) {
21+
const isMatch = minimatch(e, path.join('**', executableGlob), { nocase: getOSType() === OSType.Windows });
22+
if (!isMatch) {
1923
// When deleting the file for some reason path to all directories leading up to python are reported
2024
// Skip those events
2125
return;

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

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

44
import * as fsapi from 'fs-extra';
5+
import * as minimatch from 'minimatch';
56
import * as path from 'path';
67
import { traceWarning } from '../../../../common/logger';
8+
import { FileChangeType } from '../../../../common/platform/fileSystemWatcher';
79
import { Architecture, getEnvironmentVariable } from '../../../../common/utils/platform';
8-
import {
9-
PythonEnvInfo, PythonEnvKind,
10-
} from '../../../base/info';
10+
import { PythonEnvInfo, PythonEnvKind } from '../../../base/info';
1111
import { buildEnvInfo } from '../../../base/info/env';
1212
import { getPythonVersionFromPath } from '../../../base/info/pythonVersion';
1313
import { IPythonEnvsIterator, Locator } from '../../../base/locator';
1414
import { getFileInfo } from '../../../common/externalDependencies';
15+
import { watchLocationForPythonBinaries } from '../../../common/pythonBinariesWatcher';
1516

1617
/**
1718
* Gets path to the Windows Apps directory.
@@ -29,7 +30,7 @@ export function getWindowsStoreAppsRoot(): string {
2930
* @returns {boolean} : Returns true if `interpreterPath` is under
3031
* `%ProgramFiles%/WindowsApps`.
3132
*/
32-
export function isForbiddenStorePath(interpreterPath:string):boolean {
33+
export function isForbiddenStorePath(interpreterPath: string): boolean {
3334
const programFilesStorePath = path
3435
.join(getEnvironmentVariable('ProgramFiles') || 'Program Files', 'WindowsApps')
3536
.normalize()
@@ -87,26 +88,27 @@ export async function isWindowsStoreEnvironment(interpreterPath: string): Promis
8788
return false;
8889
}
8990

91+
/**
92+
* This is a glob pattern which matches following file names:
93+
* python3.8.exe
94+
* python3.9.exe
95+
* This pattern does not match:
96+
* python.exe
97+
* python2.7.exe
98+
* python3.exe
99+
* python38.exe
100+
* 'python.exe', 'python3.exe', and 'python3.8.exe' can point to the same executable, hence only capture python3.*.exes.
101+
*/
102+
const pythonExeGlob = 'python3\.[0-9]*\.exe';
103+
90104
/**
91105
* Checks if a given path ends with python3.*.exe. Not all python executables are matched as
92106
* we do not want to return duplicate executables.
93107
* @param {string} interpreterPath : Path to python interpreter.
94108
* @returns {boolean} : Returns true if the path matches pattern for windows python executable.
95109
*/
96-
export function isWindowsStorePythonExe(interpreterPath:string): boolean {
97-
/**
98-
* This Reg-ex matches following file names:
99-
* python3.8.exe
100-
* python3.9.exe
101-
* This Reg-ex does not match:
102-
* python.exe
103-
* python2.7.exe
104-
* python3.exe
105-
* python38.exe
106-
*/
107-
const windowsPythonExes = /^python(3(.\d+))\.exe$/;
108-
109-
return windowsPythonExes.test(path.basename(interpreterPath));
110+
export function isWindowsStorePythonExe(interpreterPath: string): boolean {
111+
return minimatch(path.basename(interpreterPath), pythonExeGlob, { nocase: true });
110112
}
111113

112114
/**
@@ -131,17 +133,21 @@ export async function getWindowsStorePythonExes(): Promise<string[]> {
131133
// Collect python*.exe directly under %LOCALAPPDATA%/Microsoft/WindowsApps
132134
const files = await fsapi.readdir(windowsAppsRoot);
133135
return files
134-
.map((filename:string) => path.join(windowsAppsRoot, filename))
136+
.map((filename: string) => path.join(windowsAppsRoot, filename))
135137
.filter(isWindowsStorePythonExe);
136138
}
137139

138140
export class WindowsStoreLocator extends Locator {
139-
private readonly kind:PythonEnvKind = PythonEnvKind.WindowsStore;
141+
private readonly kind: PythonEnvKind = PythonEnvKind.WindowsStore;
142+
143+
public initialize(): void {
144+
this.startWatcher();
145+
}
140146

141147
public iterEnvs(): IPythonEnvsIterator {
142148
const iterator = async function* (kind: PythonEnvKind) {
143149
const exes = await getWindowsStorePythonExes();
144-
yield* exes.map(async (executable:string) => buildEnvInfo({
150+
yield* exes.map(async (executable: string) => buildEnvInfo({
145151
kind,
146152
executable,
147153
version: getPythonVersionFromPath(executable),
@@ -167,4 +173,15 @@ export class WindowsStoreLocator extends Locator {
167173
}
168174
return undefined;
169175
}
176+
177+
private startWatcher(): void {
178+
const windowsAppsRoot = getWindowsStoreAppsRoot();
179+
watchLocationForPythonBinaries(
180+
windowsAppsRoot,
181+
(type: FileChangeType) => {
182+
this.emitter.fire({ type, kind: this.kind });
183+
},
184+
pythonExeGlob,
185+
);
186+
}
170187
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import { assert } from 'chai';
5+
import * as fs from 'fs-extra';
6+
import * as path from 'path';
7+
import { traceWarning } from '../../../../client/common/logger';
8+
import { FileChangeType } from '../../../../client/common/platform/fileSystemWatcher';
9+
import { createDeferred, Deferred, sleep } from '../../../../client/common/utils/async';
10+
import { PythonEnvKind } from '../../../../client/pythonEnvironments/base/info';
11+
import { getEnvs } from '../../../../client/pythonEnvironments/base/locatorUtils';
12+
import { PythonEnvsChangedEvent } from '../../../../client/pythonEnvironments/base/watcher';
13+
import { arePathsSame } from '../../../../client/pythonEnvironments/common/externalDependencies';
14+
import { WindowsStoreLocator } from '../../../../client/pythonEnvironments/discovery/locators/services/windowsStoreLocator';
15+
import { TEST_TIMEOUT } from '../../../constants';
16+
import { TEST_LAYOUT_ROOT } from '../../common/commonTestConstants';
17+
18+
class WindowsStoreEnvs {
19+
private executables: string[] = [];
20+
21+
constructor(private readonly storeAppRoot: string) {}
22+
23+
public async create(basename: string): Promise<string> {
24+
const filename = path.join(this.storeAppRoot, basename);
25+
try {
26+
await fs.createFile(filename);
27+
} catch (err) {
28+
throw new Error(`Failed to create Windows Apps executable ${filename}, Error: ${err}`);
29+
}
30+
this.executables.push(filename);
31+
return filename;
32+
}
33+
34+
public async update(basename: string): Promise<void> {
35+
const filename = path.join(this.storeAppRoot, basename);
36+
try {
37+
await fs.writeFile(filename, 'Environment has been updated');
38+
} catch (err) {
39+
throw new Error(`Failed to update Windows Apps executable ${filename}, Error: ${err}`);
40+
}
41+
}
42+
43+
public async cleanUp() {
44+
await Promise.all(
45+
this.executables.map(async (filename: string) => {
46+
try {
47+
await fs.remove(filename);
48+
} catch (err) {
49+
traceWarning(`Failed to clean up ${filename}`);
50+
}
51+
}),
52+
);
53+
}
54+
}
55+
56+
suite('Windows Store Locator', async () => {
57+
const testLocalAppData = path.join(TEST_LAYOUT_ROOT, 'storeApps');
58+
const testStoreAppRoot = path.join(testLocalAppData, 'Microsoft', 'WindowsApps');
59+
const windowsStoreEnvs = new WindowsStoreEnvs(testStoreAppRoot);
60+
let locator: WindowsStoreLocator;
61+
const localAppDataOldValue = process.env.LOCALAPPDATA;
62+
63+
async function waitForChangeToBeDetected(deferred: Deferred<void>) {
64+
const timeout = setTimeout(
65+
() => {
66+
clearTimeout(timeout);
67+
deferred.reject(new Error('Environment not detected'));
68+
},
69+
TEST_TIMEOUT,
70+
);
71+
await deferred.promise;
72+
}
73+
74+
async function isLocated(executable: string): Promise<boolean> {
75+
const items = await getEnvs(locator.iterEnvs());
76+
return items.some((item) => arePathsSame(item.executable.filename, executable));
77+
}
78+
79+
suiteSetup(async () => {
80+
process.env.LOCALAPPDATA = testLocalAppData;
81+
await windowsStoreEnvs.cleanUp();
82+
});
83+
84+
async function setupLocator(onChanged: (e: PythonEnvsChangedEvent) => Promise<void>) {
85+
locator = new WindowsStoreLocator();
86+
locator.initialize();
87+
// Wait for watchers to get ready
88+
await sleep(1000);
89+
locator.onChanged(onChanged);
90+
}
91+
92+
teardown(() => windowsStoreEnvs.cleanUp());
93+
suiteTeardown(async () => {
94+
process.env.LOCALAPPDATA = localAppDataOldValue;
95+
});
96+
97+
test('Detect a new environment', async () => {
98+
let actualEvent: PythonEnvsChangedEvent;
99+
const deferred = createDeferred<void>();
100+
const expectedEvent = {
101+
kind: PythonEnvKind.WindowsStore,
102+
type: FileChangeType.Created,
103+
};
104+
await setupLocator(async (e) => {
105+
actualEvent = e;
106+
deferred.resolve();
107+
});
108+
109+
const executable = await windowsStoreEnvs.create('python3.4.exe');
110+
await waitForChangeToBeDetected(deferred);
111+
const isFound = await isLocated(executable);
112+
113+
assert.ok(isFound);
114+
assert.deepEqual(actualEvent!, expectedEvent, 'Wrong event emitted');
115+
});
116+
117+
test('Detect when an environment has been deleted', async () => {
118+
let actualEvent: PythonEnvsChangedEvent;
119+
const deferred = createDeferred<void>();
120+
const expectedEvent = {
121+
kind: PythonEnvKind.WindowsStore,
122+
type: FileChangeType.Deleted,
123+
};
124+
const executable = await windowsStoreEnvs.create('python3.4.exe');
125+
// Wait before the change event has been sent. If both operations occur almost simultaneously no event is sent.
126+
await sleep(100);
127+
await setupLocator(async (e) => {
128+
actualEvent = e;
129+
deferred.resolve();
130+
});
131+
132+
await windowsStoreEnvs.cleanUp();
133+
await waitForChangeToBeDetected(deferred);
134+
const isFound = await isLocated(executable);
135+
136+
assert.notOk(isFound);
137+
assert.deepEqual(actualEvent!, expectedEvent, 'Wrong event emitted');
138+
});
139+
140+
test('Detect when an environment has been updated', async () => {
141+
let actualEvent: PythonEnvsChangedEvent;
142+
const deferred = createDeferred<void>();
143+
const expectedEvent = {
144+
kind: PythonEnvKind.WindowsStore,
145+
type: FileChangeType.Changed,
146+
};
147+
const executable = await windowsStoreEnvs.create('python3.4.exe');
148+
// Wait before the change event has been sent. If both operations occur almost simultaneously no event is sent.
149+
await sleep(100);
150+
await setupLocator(async (e) => {
151+
actualEvent = e;
152+
deferred.resolve();
153+
});
154+
155+
await windowsStoreEnvs.update('python3.4.exe');
156+
await waitForChangeToBeDetected(deferred);
157+
const isFound = await isLocated(executable);
158+
159+
assert.ok(isFound);
160+
assert.deepEqual(actualEvent!, expectedEvent, 'Wrong event emitted');
161+
});
162+
});

0 commit comments

Comments
 (0)