Skip to content

Added Windows store file system watcher #14577

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Nov 3, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/client/pythonEnvironments/common/pythonBinariesWatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,23 @@

'use strict';

import * as minimatch from 'minimatch';
import * as path from 'path';
import { FileChangeType, watchLocationForPattern } from '../../common/platform/fileSystemWatcher';
import { getOSType, OSType } from '../../common/utils/platform';

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

export function watchLocationForPythonBinaries(
baseDir: string,
callback: (type: FileChangeType, absPath: string) => void,
executableGlob: string = executable,
): void {
const patterns = [executableGlob, `*/${executableGlob}`, `*/${binName}/${executableGlob}`];

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be helpful to callers to throw an error here if executableGlob has path.separator in it. I still think a similar check for wildcards (*) would be likewise helpful.

Copy link
Author

@karrtikr karrtikr Nov 3, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went with your initial suggestion to expand the glob pattern to support more than base patterns. #14577 (comment)

So we don't have to put a restriction on the wildcards or path separators. I can rename it to executableSuffixGlob if that would help.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went with your initial suggestion to expand the glob pattern to support more than base patterns.

I don't see that. What I see is:

const pythonExeGlob = 'python3\.[0-9]*\.exe';

A more-than-base pattern would be:

const pythonExeGlob = '**/python3\.[0-9]*\.exe';

or:

const pythonExeGlob = path.join('**', 'python3\.[0-9]*\.exe');

Copy link
Author

@karrtikr karrtikr Nov 3, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm talking about the API, not this particular usecase. The API supports more than base patterns, i.e user can pass in a path.join('**', 'python3\.[0-9]*\.exe') and things should work. Just needed to do this here.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

path.join('**', executablesGlob) is exactly what I would expect this function to do for the caller.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you're assuming executablesGlob to be the base executable glob, it's not. It refers to the suffix of the full executable path we intend to watch. For eg. it can be path.join('**', 'python3\.[0-9]*\.exe') itself, so the function need not do anything for the caller.

Hence why I suggested to rename it to executablesSuffixGlob.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The patterns on this line suggest otherwise:

  • executableGlob - look for the file in the current directory
  • */${executableGlob} - look for the file one level down
  • */${binName}/${executableGlob} - look for the file in the "bin" directory one level down

What you seen to be saying is that a caller may want to search at some arbitrary depth relative to a given root:

  • **/python3.exe
  • */**/python3.exe
  • */${binName}/**/python3.exe

However, those don't seem right to me. Perhaps you meant a user might want to do something like the following?

  • **/python3.exe
  • **/*/python3.exe
  • **/*/${binName}/python3.exe

The problem is that kind of defeats the structure-oriented design of this function. So I don't see that to be right either.

All this is why I would expect executableGlob to always be a base name.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What you seen to be saying is that a caller may want to search at some arbitrary depth relative to a given root:

  • **/python3.exe
  • */**/python3.exe
  • */${binName}/**/python3.exe
    However, those don't seem right to me.

I realize it doesn't seem right. But if you look closely, the first pattern is enough to do what the user asks (watch for suffixes ending with python3.exe). And the second and third pattern are just subsets of the first. So yes, they do what the user intended to.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is only true if the glob is for the basename. If they put **/... then it won't be what they expect. If it shouldn't matter then this function should not be auto-watching */${executableGlob} and */${binName}/${executableGlob}. The two things just don't make sense together.

for (const pattern of patterns) {
watchLocationForPattern(baseDir, pattern, (type: FileChangeType, e: string) => {
if (!e.endsWith(executable)) {
const isMatch = minimatch(e, path.join('**', executableGlob), { nocase: getOSType() === OSType.Windows });
if (!isMatch) {
// When deleting the file for some reason path to all directories leading up to python are reported
// Skip those events
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@
// Licensed under the MIT License.

import * as fsapi from 'fs-extra';
import * as minimatch from 'minimatch';
import * as path from 'path';
import { traceWarning } from '../../../../common/logger';
import { FileChangeType } from '../../../../common/platform/fileSystemWatcher';
import { Architecture, getEnvironmentVariable } from '../../../../common/utils/platform';
import {
PythonEnvInfo, PythonEnvKind,
} from '../../../base/info';
import { PythonEnvInfo, PythonEnvKind } from '../../../base/info';
import { buildEnvInfo } from '../../../base/info/env';
import { getPythonVersionFromPath } from '../../../base/info/pythonVersion';
import { IPythonEnvsIterator, Locator } from '../../../base/locator';
import { getFileInfo } from '../../../common/externalDependencies';
import { watchLocationForPythonBinaries } from '../../../common/pythonBinariesWatcher';

/**
* Gets path to the Windows Apps directory.
Expand All @@ -29,7 +30,7 @@ export function getWindowsStoreAppsRoot(): string {
* @returns {boolean} : Returns true if `interpreterPath` is under
* `%ProgramFiles%/WindowsApps`.
*/
export function isForbiddenStorePath(interpreterPath:string):boolean {
export function isForbiddenStorePath(interpreterPath: string): boolean {
const programFilesStorePath = path
.join(getEnvironmentVariable('ProgramFiles') || 'Program Files', 'WindowsApps')
.normalize()
Expand Down Expand Up @@ -87,26 +88,27 @@ export async function isWindowsStoreEnvironment(interpreterPath: string): Promis
return false;
}

/**
* This is a glob pattern which matches following file names:
* python3.8.exe
* python3.9.exe
* This pattern does not match:
* python.exe
* python2.7.exe
* python3.exe
* python38.exe
* 'python.exe', 'python3.exe', and 'python3.8.exe' can point to the same executable, hence only capture python3.*.exes.
*/
const pythonExeGlob = 'python3\.[0-9]*\.exe';

/**
* Checks if a given path ends with python3.*.exe. Not all python executables are matched as
* we do not want to return duplicate executables.
* @param {string} interpreterPath : Path to python interpreter.
* @returns {boolean} : Returns true if the path matches pattern for windows python executable.
*/
export function isWindowsStorePythonExe(interpreterPath:string): boolean {
/**
* This Reg-ex matches following file names:
* python3.8.exe
* python3.9.exe
* This Reg-ex does not match:
* python.exe
* python2.7.exe
* python3.exe
* python38.exe
*/
const windowsPythonExes = /^python(3(.\d+))\.exe$/;

return windowsPythonExes.test(path.basename(interpreterPath));
export function isWindowsStorePythonExe(interpreterPath: string): boolean {
return minimatch(path.basename(interpreterPath), pythonExeGlob, { nocase: true });
}
Comment on lines +110 to 112

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why aren't we just using isWindowsPythonExe()?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because of the reason mentioned in the sprint demo today: We're matching only python3.*.exes so that we do collect only unique executables. isWindowsPythonExe() also matches with python.exe which is not what we want.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc comment explains this.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then I'd expect the following to take care of it:

Suggested change
export function isWindowsStorePythonExe(interpreterPath: string): boolean {
return minimatch(path.basename(interpreterPath), pythonExeGlob, { nocase: true });
}
export function isWindowsStorePythonExe(interpreterPath: string): boolean {
if (!isWindowsPythonExe(interpreterPath)) {
return false;
}
return path.basename(interpreterPath) !== 'python.exe';
}

That way we don't accept executables we shouldn't, like "python3-whoa!!!.exe".

Copy link
Author

@karrtikr karrtikr Nov 4, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, there's also python3.exe, and python2.exe that we would need to ignore. I can either do this or correct the glob pattern to not accept the whoaaa string. I'll have to correct the glob either way so we don't watch for such executables.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, so we're okay with "extended glob". What you have looks fine, but I'd change the * to ?.


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

export class WindowsStoreLocator extends Locator {
private readonly kind:PythonEnvKind = PythonEnvKind.WindowsStore;
private readonly kind: PythonEnvKind = PythonEnvKind.WindowsStore;

public initialize(): void {
this.startWatcher();
}

public iterEnvs(): IPythonEnvsIterator {
const iterator = async function* (kind: PythonEnvKind) {
const exes = await getWindowsStorePythonExes();
yield* exes.map(async (executable:string) => buildEnvInfo({
yield* exes.map(async (executable: string) => buildEnvInfo({
kind,
executable,
version: getPythonVersionFromPath(executable),
Expand All @@ -167,4 +173,15 @@ export class WindowsStoreLocator extends Locator {
}
return undefined;
}

private startWatcher(): void {
const windowsAppsRoot = getWindowsStoreAppsRoot();
watchLocationForPythonBinaries(
windowsAppsRoot,
(type: FileChangeType) => {
this.emitter.fire({ type, kind: this.kind });
},
pythonExeGlob,
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

import { assert } from 'chai';
import * as fs from 'fs-extra';
import * as path from 'path';
import { traceWarning } from '../../../../client/common/logger';
import { FileChangeType } from '../../../../client/common/platform/fileSystemWatcher';
import { createDeferred, Deferred, sleep } from '../../../../client/common/utils/async';
import { PythonEnvKind } from '../../../../client/pythonEnvironments/base/info';
import { getEnvs } from '../../../../client/pythonEnvironments/base/locatorUtils';
import { PythonEnvsChangedEvent } from '../../../../client/pythonEnvironments/base/watcher';
import { arePathsSame } from '../../../../client/pythonEnvironments/common/externalDependencies';
import { WindowsStoreLocator } from '../../../../client/pythonEnvironments/discovery/locators/services/windowsStoreLocator';
import { TEST_TIMEOUT } from '../../../constants';
import { TEST_LAYOUT_ROOT } from '../../common/commonTestConstants';

class WindowsStoreEnvs {
private executables: string[] = [];

constructor(private readonly storeAppRoot: string) {}

public async create(basename: string): Promise<string> {
const filename = path.join(this.storeAppRoot, basename);
try {
await fs.createFile(filename);
} catch (err) {
throw new Error(`Failed to create Windows Apps executable ${filename}, Error: ${err}`);
}
this.executables.push(filename);
return filename;
}

public async update(basename: string): Promise<void> {
const filename = path.join(this.storeAppRoot, basename);
try {
await fs.writeFile(filename, 'Environment has been updated');
} catch (err) {
throw new Error(`Failed to update Windows Apps executable ${filename}, Error: ${err}`);
}
}

public async cleanUp() {
await Promise.all(
this.executables.map(async (filename: string) => {
try {
await fs.remove(filename);
} catch (err) {
traceWarning(`Failed to clean up ${filename}`);
}
}),
);
}
}

suite('Windows Store Locator', async () => {
const testLocalAppData = path.join(TEST_LAYOUT_ROOT, 'storeApps');
const testStoreAppRoot = path.join(testLocalAppData, 'Microsoft', 'WindowsApps');
const windowsStoreEnvs = new WindowsStoreEnvs(testStoreAppRoot);
let locator: WindowsStoreLocator;
const localAppDataOldValue = process.env.LOCALAPPDATA;

async function waitForChangeToBeDetected(deferred: Deferred<void>) {
const timeout = setTimeout(
() => {
clearTimeout(timeout);
deferred.reject(new Error('Environment not detected'));
},
TEST_TIMEOUT,
);
await deferred.promise;
}

async function isLocated(executable: string): Promise<boolean> {
const items = await getEnvs(locator.iterEnvs());
return items.some((item) => arePathsSame(item.executable.filename, executable));
}

suiteSetup(async () => {
process.env.LOCALAPPDATA = testLocalAppData;
await windowsStoreEnvs.cleanUp();
});

async function setupLocator(onChanged: (e: PythonEnvsChangedEvent) => Promise<void>) {
locator = new WindowsStoreLocator();
locator.initialize();
// Wait for watchers to get ready
await sleep(1000);
locator.onChanged(onChanged);
}

teardown(() => windowsStoreEnvs.cleanUp());
suiteTeardown(async () => {
process.env.LOCALAPPDATA = localAppDataOldValue;
});

test('Detect a new environment', async () => {
let actualEvent: PythonEnvsChangedEvent;
const deferred = createDeferred<void>();
const expectedEvent = {
kind: PythonEnvKind.WindowsStore,
type: FileChangeType.Created,
};
await setupLocator(async (e) => {
actualEvent = e;
deferred.resolve();
});

const executable = await windowsStoreEnvs.create('python3.4.exe');
await waitForChangeToBeDetected(deferred);
const isFound = await isLocated(executable);

assert.ok(isFound);
assert.deepEqual(actualEvent!, expectedEvent, 'Wrong event emitted');
});

test('Detect when an environment has been deleted', async () => {
let actualEvent: PythonEnvsChangedEvent;
const deferred = createDeferred<void>();
const expectedEvent = {
kind: PythonEnvKind.WindowsStore,
type: FileChangeType.Deleted,
};
const executable = await windowsStoreEnvs.create('python3.4.exe');
// Wait before the change event has been sent. If both operations occur almost simultaneously no event is sent.
await sleep(100);
await setupLocator(async (e) => {
actualEvent = e;
deferred.resolve();
});

await windowsStoreEnvs.cleanUp();
await waitForChangeToBeDetected(deferred);
const isFound = await isLocated(executable);

assert.notOk(isFound);
assert.deepEqual(actualEvent!, expectedEvent, 'Wrong event emitted');
});

test('Detect when an environment has been updated', async () => {
let actualEvent: PythonEnvsChangedEvent;
const deferred = createDeferred<void>();
const expectedEvent = {
kind: PythonEnvKind.WindowsStore,
type: FileChangeType.Changed,
};
const executable = await windowsStoreEnvs.create('python3.4.exe');
// Wait before the change event has been sent. If both operations occur almost simultaneously no event is sent.
await sleep(100);
await setupLocator(async (e) => {
actualEvent = e;
deferred.resolve();
});

await windowsStoreEnvs.update('python3.4.exe');
await waitForChangeToBeDetected(deferred);
const isFound = await isLocated(executable);

assert.ok(isFound);
assert.deepEqual(actualEvent!, expectedEvent, 'Wrong event emitted');
});
});