Skip to content

Watch for new conda environments #19877

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 5 commits into from
Sep 24, 2022
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,22 @@
// Licensed under the MIT License.
import '../../../../common/extensions';
import { PythonEnvKind } from '../../info';
import { BasicEnvInfo, IPythonEnvsIterator, Locator } from '../../locator';
import { Conda } from '../../../common/environmentManagers/conda';
import { BasicEnvInfo, IPythonEnvsIterator } from '../../locator';
import { Conda, getCondaEnvironmentsTxt } from '../../../common/environmentManagers/conda';
import { traceError, traceVerbose } from '../../../../logging';
import { FSWatchingLocator } from './fsWatchingLocator';

export class CondaEnvironmentLocator extends FSWatchingLocator<BasicEnvInfo> {
public constructor() {
super(
() => getCondaEnvironmentsTxt(),
async () => PythonEnvKind.Conda,
{ isFile: true },
);
}

export class CondaEnvironmentLocator extends Locator<BasicEnvInfo> {
// eslint-disable-next-line class-methods-use-this
public async *iterEnvs(): IPythonEnvsIterator<BasicEnvInfo> {
public async *doIterEnvs(): IPythonEnvsIterator<BasicEnvInfo> {
const conda = await Conda.getConda();
if (conda === undefined) {
traceVerbose(`Couldn't locate the conda binary.`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { Uri } from 'vscode';
import { FileChangeType } from '../../../../common/platform/fileSystemWatcher';
import { FileChangeType, watchLocationForPattern } from '../../../../common/platform/fileSystemWatcher';
import { sleep } from '../../../../common/utils/async';
import { traceError, traceVerbose } from '../../../../logging';
import { getEnvironmentDirFromPath } from '../../../common/commonUtils';
Expand Down Expand Up @@ -47,6 +47,33 @@ function checkDirWatchable(dirname: string): DirUnwatchableReason {
return undefined;
}

type LocationWatchOptions = {
/**
* Glob which represents basename of the executable or directory to watch.
*/
baseGlob?: string;
/**
* Time to wait before handling an environment-created event.
*/
delayOnCreated?: number; // milliseconds
/**
* Location affected by the event. If not provided, a default search location is used.
*/
searchLocation?: string;
/**
* The Python env structure to watch.
*/
envStructure?: PythonEnvStructure;
};

type FileWatchOptions = {
/**
* If the provided root is a file instead. In this case the file is directly watched instead for
* looking for python binaries inside a root.
*/
isFile: boolean;
};

/**
* The base for Python envs locators who watch the file system.
* Most low-level locators should be using this.
Expand All @@ -63,24 +90,7 @@ export abstract class FSWatchingLocator<I = PythonEnvInfo> extends LazyResourceB
* Returns the kind of environment specific to locator given the path to executable.
*/
private readonly getKind: (executable: string) => Promise<PythonEnvKind>,
private readonly opts: {
/**
* Glob which represents basename of the executable or directory to watch.
*/
baseGlob?: string;
/**
* Time to wait before handling an environment-created event.
*/
delayOnCreated?: number; // milliseconds
/**
* Location affected by the event. If not provided, a default search location is used.
*/
searchLocation?: string;
/**
* The Python env structure to watch.
*/
envStructure?: PythonEnvStructure;
} = {},
private readonly creationOptions: LocationWatchOptions | FileWatchOptions = {},
private readonly watcherKind: FSWatcherKind = FSWatcherKind.Global,
) {
super();
Expand All @@ -89,8 +99,8 @@ export abstract class FSWatchingLocator<I = PythonEnvInfo> extends LazyResourceB

protected async initWatchers(): Promise<void> {
// Enable all workspace watchers.
if (this.watcherKind === FSWatcherKind.Global) {
// Do not allow global watchers for now
if (this.watcherKind === FSWatcherKind.Global && !isWatchingAFile(this.creationOptions)) {
// Do not allow global location watchers for now.
return;
}

Expand All @@ -102,6 +112,9 @@ export abstract class FSWatchingLocator<I = PythonEnvInfo> extends LazyResourceB
roots = [roots];
}
const promises = roots.map(async (root) => {
if (isWatchingAFile(this.creationOptions)) {
return root;
}
// Note that we only check the root dir. Any directories
// that might be watched due to a glob are not checked.
const unwatchable = await checkDirWatchable(root);
Expand All @@ -116,12 +129,23 @@ export abstract class FSWatchingLocator<I = PythonEnvInfo> extends LazyResourceB
}

private startWatchers(root: string): void {
const opts = this.creationOptions;
if (isWatchingAFile(opts)) {
traceVerbose('Start watching file for changes', root);
this.disposables.push(
watchLocationForPattern(path.dirname(root), path.basename(root), () => {
traceVerbose('Detected change in file: ', root, 'initiating a refresh');
this.emitter.fire({});
}),
);
return;
}
const callback = async (type: FileChangeType, executable: string) => {
if (type === FileChangeType.Created) {
if (this.opts.delayOnCreated !== undefined) {
if (opts.delayOnCreated !== undefined) {
// Note detecting kind of env depends on the file structure around the
// executable, so we need to wait before attempting to detect it.
await sleep(this.opts.delayOnCreated);
await sleep(opts.delayOnCreated);
}
}
// Fetching kind after deletion normally fails because the file structure around the
Expand All @@ -135,20 +159,22 @@ export abstract class FSWatchingLocator<I = PythonEnvInfo> extends LazyResourceB
// |__ env
// |__ bin or Scripts
// |__ python <--- executable
const searchLocation = Uri.file(
this.opts.searchLocation ?? path.dirname(getEnvironmentDirFromPath(executable)),
);
const searchLocation = Uri.file(opts.searchLocation ?? path.dirname(getEnvironmentDirFromPath(executable)));
traceVerbose('Fired event ', JSON.stringify({ type, kind, searchLocation }), 'from locator');
this.emitter.fire({ type, kind, searchLocation });
};

const globs = resolvePythonExeGlobs(
this.opts.baseGlob,
opts.baseGlob,
// The structure determines which globs are returned.
this.opts.envStructure,
opts.envStructure,
);
traceVerbose('Start watching root', root, 'for globs', JSON.stringify(globs));
const watchers = globs.map((g) => watchLocationForPythonBinaries(root, callback, g));
this.disposables.push(...watchers);
}
}

function isWatchingAFile(options: LocationWatchOptions | FileWatchOptions): options is FileWatchOptions {
return 'isFile' in options && options.isFile;
}
12 changes: 12 additions & 0 deletions src/client/pythonEnvironments/common/environmentManagers/conda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,18 @@ export async function isCondaEnvironment(interpreterPathOrEnvPath: string): Prom
return false;
}

/**
* Gets path to conda's `environments.txt` file. More info https://github.com/conda/conda/issues/11845.
*/
export async function getCondaEnvironmentsTxt(): Promise<string[]> {
const homeDir = getUserHomeDir();
if (!homeDir) {
return [];
}
const environmentsTxt = path.join(homeDir, '.conda', 'environments.txt');
return [environmentsTxt];
}

/**
* Extracts version information from `conda-meta/history` near a given interpreter.
* @param interpreterPath Absolute path to the interpreter
Expand Down
23 changes: 1 addition & 22 deletions src/client/pythonEnvironments/legacyIOC.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,6 @@ function convertEnvInfo(info: PythonEnvInfo): PythonEnvironment {
}
@injectable()
class ComponentAdapter implements IComponentAdapter {
private readonly refreshing = new vscode.EventEmitter<void>();

private readonly refreshed = new vscode.EventEmitter<void>();

private readonly changed = new vscode.EventEmitter<PythonEnvironmentsChangedEvent>();

constructor(
Expand Down Expand Up @@ -135,15 +131,6 @@ class ComponentAdapter implements IComponentAdapter {
});
}

// Implements IInterpreterLocatorProgressHandler
public get onRefreshing(): vscode.Event<void> {
return this.refreshing.event;
}

public get onRefreshed(): vscode.Event<void> {
return this.refreshed.event;
}

// Implements IInterpreterHelper
public async getInterpreterInformation(pythonPath: string): Promise<Partial<PythonEnvironment> | undefined> {
const env = await this.api.resolveEnv(pythonPath);
Expand Down Expand Up @@ -231,9 +218,6 @@ class ComponentAdapter implements IComponentAdapter {
}

public getInterpreters(resource?: vscode.Uri, source?: PythonEnvSource[]): PythonEnvironment[] {
// Notify locators are locating.
this.refreshing.fire();

const query: PythonLocatorQuery = {};
let roots: vscode.Uri[] = [];
let wsFolder: vscode.WorkspaceFolder | undefined;
Expand Down Expand Up @@ -262,12 +246,7 @@ class ComponentAdapter implements IComponentAdapter {
envs = envs.filter((env) => intersection(source, env.source).length > 0);
}

const legacyEnvs = envs.map(convertEnvInfo);

// Notify all locators have completed locating. Note it's crucial to notify this even when getInterpretersViaAPI
// fails, to ensure "Python extension loading..." text disappears.
this.refreshed.fire();
return legacyEnvs;
return envs.map(convertEnvInfo);
}

public async getWorkspaceVirtualEnvInterpreters(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

import * as path from 'path';
import * as fs from 'fs-extra';
import { assert } from 'chai';
import * as sinon from 'sinon';
import * as platformUtils from '../../../../../client/common/utils/platform';
import { CondaEnvironmentLocator } from '../../../../../client/pythonEnvironments/base/locators/lowLevel/condaLocator';
import { sleep } from '../../../../core';
import { createDeferred, Deferred } from '../../../../../client/common/utils/async';
import { PythonEnvsChangedEvent } from '../../../../../client/pythonEnvironments/base/watcher';
import { TEST_TIMEOUT } from '../../../../constants';
import { traceWarn } from '../../../../../client/logging';
import { TEST_LAYOUT_ROOT } from '../../../common/commonTestConstants';

class CondaEnvs {
private readonly condaEnvironmentsTxt;

constructor() {
const home = platformUtils.getUserHomeDir();
if (!home) {
throw new Error('Home directory not found');
}
this.condaEnvironmentsTxt = path.join(home, '.conda', 'environments.txt');
}

public async create(): Promise<void> {
try {
await fs.createFile(this.condaEnvironmentsTxt);
} catch (err) {
throw new Error(`Failed to create environments.txt ${this.condaEnvironmentsTxt}, Error: ${err}`);
}
}

public async update(): Promise<void> {
try {
await fs.writeFile(this.condaEnvironmentsTxt, 'path/to/environment');
} catch (err) {
throw new Error(`Failed to update environments file ${this.condaEnvironmentsTxt}, Error: ${err}`);
}
}

public async cleanUp() {
try {
await fs.remove(this.condaEnvironmentsTxt);
} catch (err) {
traceWarn(`Failed to clean up ${this.condaEnvironmentsTxt}`);
}
}
}

suite('Conda Env Watcher', async () => {
let locator: CondaEnvironmentLocator;
let condaEnvsTxt: CondaEnvs;

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

setup(async () => {
sinon.stub(platformUtils, 'getUserHomeDir').returns(TEST_LAYOUT_ROOT);
condaEnvsTxt = new CondaEnvs();
await condaEnvsTxt.cleanUp();
});

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

teardown(async () => {
await condaEnvsTxt.cleanUp();
await locator.dispose();
sinon.restore();
});

test('Fires when conda `environments.txt` file is created', async () => {
let actualEvent: PythonEnvsChangedEvent;
const deferred = createDeferred<void>();
const expectedEvent = {};
await setupLocator(async (e) => {
deferred.resolve();
actualEvent = e;
});

await condaEnvsTxt.create();
await waitForChangeToBeDetected(deferred);

assert.deepEqual(actualEvent!, expectedEvent, 'Unexpected event emitted');
});

test('Fires when conda `environments.txt` file is updated', async () => {
let actualEvent: PythonEnvsChangedEvent;
const deferred = createDeferred<void>();
const expectedEvent = {};
await condaEnvsTxt.create();
await setupLocator(async (e) => {
deferred.resolve();
actualEvent = e;
});

await condaEnvsTxt.update();
await waitForChangeToBeDetected(deferred);

assert.deepEqual(actualEvent!, expectedEvent, 'Unexpected event emitted');
});
});