Skip to content
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
2 changes: 1 addition & 1 deletion src/client/common/platform/fileSystemWatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ function watchLocationUsingChokidar(
'**/lib/**',
'**/includes/**'
], // https://github.com/microsoft/vscode/issues/23954
followSymlinks: false
followSymlinks: true
Comment thread
kimadeline marked this conversation as resolved.
};
traceVerbose(`Start watching: ${baseDir} with pattern ${pattern} using chokidar`);
let watcher: chokidar.FSWatcher | null = chokidar.watch(pattern, watcherOpts);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -128,3 +133,9 @@ export class WorkspaceVirtualEnvironmentLocator extends Locator {
return undefined;
}
}

export async function createWorkspaceVirtualEnvLocator(root: string): Promise<IDisposableLocator> {
const locator = new WorkspaceVirtualEnvironmentLocator(root);
await locator.initialize();
return locator;
}
2 changes: 0 additions & 2 deletions src/client/pythonEnvironments/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -101,7 +100,6 @@ async function initLocators(): Promise<ExtensionLocators> {

const workspaceLocators = new WorkspaceLocators([
// Add an ILocator factory func here for each kind of workspace-rooted locator.
(root: vscode.Uri) => [new WorkspaceVirtualEnvironmentLocator(root.fsPath)],

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Connecting this locator to the external surface changes quite a few things, will do that in a separate PR.

]);

// Any non-workspace locator activation goes here.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string> {
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<void> {
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<void> {
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<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 () => workspaceVenvs.cleanUp());

async function setupLocator(onChanged: (e: PythonEnvsChangedEvent) => Promise<void>) {
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<void>();
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<void>();
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<void>();
// 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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,18 @@ 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';

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,
Expand Down Expand Up @@ -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();
Expand Down