diff --git a/src/client/common/platform/fileSystemWatcher.ts b/src/client/common/platform/fileSystemWatcher.ts index 2486b0c0a619..a7120d4e9178 100644 --- a/src/client/common/platform/fileSystemWatcher.ts +++ b/src/client/common/platform/fileSystemWatcher.ts @@ -76,7 +76,7 @@ function watchLocationUsingChokidar( '**/lib/**', '**/includes/**' ], // https://github.com/microsoft/vscode/issues/23954 - followSymlinks: false + followSymlinks: true }; traceVerbose(`Start watching: ${baseDir} with pattern ${pattern} using chokidar`); let watcher: chokidar.FSWatcher | null = chokidar.watch(pattern, watcherOpts); diff --git a/src/client/pythonEnvironments/base/locators/lowLevel/fsWatchingLocator.ts b/src/client/pythonEnvironments/base/locators/lowLevel/fsWatchingLocator.ts index 67dfc224f039..cdaa8dad1c13 100644 --- a/src/client/pythonEnvironments/base/locators/lowLevel/fsWatchingLocator.ts +++ b/src/client/pythonEnvironments/base/locators/lowLevel/fsWatchingLocator.ts @@ -29,11 +29,11 @@ export abstract class FSWatchingLocator extends Locator { /** * Glob which represents basename of the executable to watch. */ - executableBaseGlob?: string, + executableBaseGlob?: string; /** * Time to wait before handling an environment-created event. */ - delayOnCreated?: number, // milliseconds + delayOnCreated?: number; // milliseconds } = {}, ) { super(); @@ -72,7 +72,9 @@ export abstract class FSWatchingLocator extends Locator { await sleep(this.opts.delayOnCreated); } } - const kind = await this.getKind(executable); + // Fetching kind after deletion normally fails because the file structure around the + // executable is no longer available, so ignore the errors. + const kind = await this.getKind(executable).catch(() => undefined); this.emitter.fire({ type, kind }); }, this.opts.executableBaseGlob, diff --git a/src/client/pythonEnvironments/base/locators/lowLevel/workspaceVirtualEnvLocator.ts b/src/client/pythonEnvironments/base/locators/lowLevel/workspaceVirtualEnvLocator.ts index 819b743bf917..097f0f0de16b 100644 --- a/src/client/pythonEnvironments/base/locators/lowLevel/workspaceVirtualEnvLocator.ts +++ b/src/client/pythonEnvironments/base/locators/lowLevel/workspaceVirtualEnvLocator.ts @@ -15,7 +15,8 @@ import { isPipenvEnvironment } from '../../../discovery/locators/services/pipEnv import { isVenvEnvironment, isVirtualenvEnvironment } from '../../../discovery/locators/services/virtualEnvironmentIdentifier'; import { PythonEnvInfo, PythonEnvKind } from '../../info'; import { buildEnvInfo } from '../../info/env'; -import { IPythonEnvsIterator, Locator } from '../../locator'; +import { IDisposableLocator, IPythonEnvsIterator } from '../../locator'; +import { FSWatchingLocator } from './fsWatchingLocator'; /** * Default number of levels of sub-directories to recurse when looking for interpreters. @@ -75,9 +76,13 @@ async function buildSimpleVirtualEnvInfo(executablePath: string, kind: PythonEnv /** * Finds and resolves virtual environments created in workspace roots. */ -export class WorkspaceVirtualEnvironmentLocator extends Locator { +class WorkspaceVirtualEnvironmentLocator extends FSWatchingLocator { public constructor(private readonly root: string) { - super(); + super(() => getWorkspaceVirtualEnvDirs(this.root), getVirtualEnvKind, { + // Note detecting kind of virtual env depends on the file structure around the + // executable, so we need to wait before attempting to detect it. + delayOnCreated: 1000, + }); } public iterEnvs(): IPythonEnvsIterator { @@ -128,3 +133,9 @@ export class WorkspaceVirtualEnvironmentLocator extends Locator { return undefined; } } + +export async function createWorkspaceVirtualEnvLocator(root: string): Promise { + const locator = new WorkspaceVirtualEnvironmentLocator(root); + await locator.initialize(); + return locator; +} diff --git a/src/client/pythonEnvironments/index.ts b/src/client/pythonEnvironments/index.ts index 12b9d084d620..a540b6874612 100644 --- a/src/client/pythonEnvironments/index.ts +++ b/src/client/pythonEnvironments/index.ts @@ -11,7 +11,6 @@ import { IDisposableLocator, IPythonEnvsIterator, PythonLocatorQuery } from './base/locator'; import { CachingLocator } from './base/locators/composite/cachingLocator'; -import { WorkspaceVirtualEnvironmentLocator } from './base/locators/lowLevel/workspaceVirtualEnvLocator'; import { PythonEnvsChangedEvent } from './base/watcher'; import { getGlobalPersistentStore, initializeExternalDependencies as initializeLegacyExternalDependencies } from './common/externalDependencies'; import { ExtensionLocators, WorkspaceLocators } from './discovery/locators'; @@ -101,7 +100,6 @@ async function initLocators(): Promise { const workspaceLocators = new WorkspaceLocators([ // Add an ILocator factory func here for each kind of workspace-rooted locator. - (root: vscode.Uri) => [new WorkspaceVirtualEnvironmentLocator(root.fsPath)], ]); // Any non-workspace locator activation goes here. diff --git a/src/test/pythonEnvironments/base/locators/lowLevel/workspaceVirtualEnvLocator.testvirtualenvs.ts b/src/test/pythonEnvironments/base/locators/lowLevel/workspaceVirtualEnvLocator.testvirtualenvs.ts new file mode 100644 index 000000000000..412fa9e88860 --- /dev/null +++ b/src/test/pythonEnvironments/base/locators/lowLevel/workspaceVirtualEnvLocator.testvirtualenvs.ts @@ -0,0 +1,187 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// eslint-disable-next-line max-classes-per-file +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 { getOSType, OSType } from '../../../../../client/common/utils/platform'; +import { IDisposableLocator } from '../../../../../client/pythonEnvironments/base/locator'; +import { createWorkspaceVirtualEnvLocator } from '../../../../../client/pythonEnvironments/base/locators/lowLevel/workspaceVirtualEnvLocator'; +import { getEnvs } from '../../../../../client/pythonEnvironments/base/locatorUtils'; +import { PythonEnvsChangedEvent } from '../../../../../client/pythonEnvironments/base/watcher'; +import { getInterpreterPathFromDir } from '../../../../../client/pythonEnvironments/common/commonUtils'; +import { arePathsSame } from '../../../../../client/pythonEnvironments/common/externalDependencies'; +import { deleteFiles, PYTHON_PATH } from '../../../../common'; +import { TEST_TIMEOUT } from '../../../../constants'; +import { TEST_LAYOUT_ROOT } from '../../../common/commonTestConstants'; +import { run } from '../../../discovery/locators/envTestUtils'; + +class WorkspaceVenvs { + constructor(private readonly root: string, private readonly prefix = '.virtual') { } + + public async create(name: string): Promise { + const envName = this.resolve(name); + const argv = [PYTHON_PATH.fileToCommandArgument(), '-m', 'virtualenv', envName]; + try { + await run(argv, { cwd: this.root }); + } catch (err) { + throw new Error(`Failed to create Env ${path.basename(envName)} Error: ${err}`); + } + const dirToLookInto = path.join(this.root, envName); + const filename = await getInterpreterPathFromDir(dirToLookInto); + if (!filename) { + throw new Error(`No environment to update exists in ${dirToLookInto}`); + } + return filename; + } + + /** + * Creates a dummy environment by creating a fake executable. + * @param name environment suffix name to create + */ + public async createDummyEnv(name: string): Promise { + const envName = this.resolve(name); + const filepath = path.join(this.root, envName, getOSType() === OSType.Windows ? 'python.exe' : 'python'); + try { + await fs.createFile(filepath); + } catch (err) { + throw new Error(`Failed to create python executable ${filepath}, Error: ${err}`); + } + return filepath; + } + + // eslint-disable-next-line class-methods-use-this + public async update(filename: string): Promise { + try { + await fs.writeFile(filename, 'Environment has been updated'); + } catch (err) { + throw new Error(`Failed to update Workspace virtualenv executable ${filename}, Error: ${err}`); + } + } + + // eslint-disable-next-line class-methods-use-this + public async delete(filename: string): Promise { + try { + await fs.remove(filename); + } catch (err) { + traceWarning(`Failed to clean up ${filename}`); + } + } + + public async cleanUp() { + const globPattern = path.join(this.root, `${this.prefix}*`); + await deleteFiles(globPattern); + } + + private resolve(name: string): string { + // Ensure env is random to avoid conflicts in tests (corrupting test data) + const now = new Date().getTime().toString().substr(-8); + return `${this.prefix}${name}${now}`; + } +} + +suite('WorkspaceVirtualEnvironment Locator', async () => { + const testWorkspaceFolder = path.join(TEST_LAYOUT_ROOT, 'workspace', 'folder1'); + const workspaceVenvs = new WorkspaceVenvs(testWorkspaceFolder); + let locator: IDisposableLocator; + + async function waitForChangeToBeDetected(deferred: Deferred) { + const timeout = setTimeout( + () => { + clearTimeout(timeout); + deferred.reject(new Error('Environment not detected')); + }, + TEST_TIMEOUT, + ); + await deferred.promise; + } + + async function isLocated(executable: string): Promise { + const items = await getEnvs(locator.iterEnvs()); + return items.some((item) => arePathsSame(item.executable.filename, executable)); + } + + suiteSetup(async () => workspaceVenvs.cleanUp()); + + async function setupLocator(onChanged: (e: PythonEnvsChangedEvent) => Promise) { + locator = await createWorkspaceVirtualEnvLocator(testWorkspaceFolder); + // Wait for watchers to get ready + await sleep(1000); + locator.onChanged(onChanged); + } + + teardown(async () => { + await workspaceVenvs.cleanUp(); + locator.dispose(); + }); + + test('Detect a new environment', async () => { + let actualEvent: PythonEnvsChangedEvent; + const deferred = createDeferred(); + await setupLocator(async (e) => { + actualEvent = e; + deferred.resolve(); + }); + + const executable = await workspaceVenvs.create('one'); + await waitForChangeToBeDetected(deferred); + const isFound = await isLocated(executable); + + assert.ok(isFound); + // Detecting kind of virtual env depends on the file structure around the executable, so we need to wait before + // attempting to verify it. Omitting that check as we can never deterministically say when it's ready to check. + assert.deepEqual(actualEvent!.type, FileChangeType.Created, 'Wrong event emitted'); + }); + + test('Detect when an environment has been deleted', async () => { + let actualEvent: PythonEnvsChangedEvent; + const deferred = createDeferred(); + const executable = await workspaceVenvs.create('one'); + // 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(); + }); + + // VSCode API has a limitation where it fails to fire event when environment folder is deleted directly: + // https://github.com/microsoft/vscode/issues/110923 + // Using chokidar directly in tests work, but it has permission issues on Windows that you cannot delete a + // folder if it has a subfolder that is being watched inside: https://github.com/paulmillr/chokidar/issues/422 + // Hence we test directly deleting the executable, and not the whole folder using `workspaceVenvs.cleanUp()`. + await workspaceVenvs.delete(executable); + await waitForChangeToBeDetected(deferred); + const isFound = await isLocated(executable); + + assert.notOk(isFound); + assert.deepEqual(actualEvent!.type, FileChangeType.Deleted, 'Wrong event emitted'); + }); + + test('Detect when an environment has been updated', async () => { + let actualEvent: PythonEnvsChangedEvent; + const deferred = createDeferred(); + // Create a dummy environment so we can update its executable later. We can't choose a real environment here. + // Executables inside real environments can be symlinks, so writing on them can result in the real executable + // being updated instead of the symlink. + const executable = await workspaceVenvs.createDummyEnv('one'); + // 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 workspaceVenvs.update(executable); + await waitForChangeToBeDetected(deferred); + const isFound = await isLocated(executable); + + assert.ok(isFound); + // Detecting kind of virtual env depends on the file structure around the executable, so we need to wait before + // attempting to verify it. Omitting that check as we can never deterministically say when it's ready to check. + assert.deepEqual(actualEvent!.type, FileChangeType.Changed, 'Wrong event emitted'); + }); +}); diff --git a/src/test/pythonEnvironments/base/locators/lowLevel/workspaceVirtualEnvLocator.unit.test.ts b/src/test/pythonEnvironments/base/locators/lowLevel/workspaceVirtualEnvLocator.unit.test.ts index 5a438b27deba..326f55a916e1 100644 --- a/src/test/pythonEnvironments/base/locators/lowLevel/workspaceVirtualEnvLocator.unit.test.ts +++ b/src/test/pythonEnvironments/base/locators/lowLevel/workspaceVirtualEnvLocator.unit.test.ts @@ -10,9 +10,10 @@ import { PythonEnvKind, PythonReleaseLevel, PythonVersion, - UNKNOWN_PYTHON_VERSION + UNKNOWN_PYTHON_VERSION, } from '../../../../../client/pythonEnvironments/base/info'; -import { WorkspaceVirtualEnvironmentLocator } from '../../../../../client/pythonEnvironments/base/locators/lowLevel/workspaceVirtualEnvLocator'; +import { IDisposableLocator } from '../../../../../client/pythonEnvironments/base/locator'; +import { createWorkspaceVirtualEnvLocator } from '../../../../../client/pythonEnvironments/base/locators/lowLevel/workspaceVirtualEnvLocator'; import { getEnvs } from '../../../../../client/pythonEnvironments/base/locatorUtils'; import { TEST_LAYOUT_ROOT } from '../../../common/commonTestConstants'; import { assertEnvEqual, assertEnvsEqual } from '../../../discovery/locators/envTestUtils'; @@ -20,7 +21,7 @@ import { assertEnvEqual, assertEnvsEqual } from '../../../discovery/locators/env suite('WorkspaceVirtualEnvironment Locator', () => { const testWorkspaceFolder = path.join(TEST_LAYOUT_ROOT, 'workspace', 'folder1'); let getOSTypeStub: sinon.SinonStub; - let locator: WorkspaceVirtualEnvironmentLocator; + let locator: IDisposableLocator; function createExpectedEnvInfo( interpreterPath: string, @@ -53,10 +54,10 @@ suite('WorkspaceVirtualEnvironment Locator', () => { assert.deepStrictEqual(actualPaths, expectedPaths); } - setup(() => { + setup(async () => { getOSTypeStub = sinon.stub(platformUtils, 'getOSType'); getOSTypeStub.returns(platformUtils.OSType.Linux); - locator = new WorkspaceVirtualEnvironmentLocator(testWorkspaceFolder); + locator = await createWorkspaceVirtualEnvLocator(testWorkspaceFolder); }); teardown(() => { getOSTypeStub.restore();