Skip to content

Commit 95bd3f8

Browse files
Kartik Rajwesm
Kartik Raj
authored andcommitted
Watch for new conda environments (microsoft/vscode-python#19877)
1 parent 50e3c9c commit 95bd3f8

File tree

5 files changed

+194
-54
lines changed

5 files changed

+194
-54
lines changed

extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/condaLocator.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,22 @@
22
// Licensed under the MIT License.
33
import '../../../../common/extensions';
44
import { PythonEnvKind } from '../../info';
5-
import { BasicEnvInfo, IPythonEnvsIterator, Locator } from '../../locator';
6-
import { Conda } from '../../../common/environmentManagers/conda';
5+
import { BasicEnvInfo, IPythonEnvsIterator } from '../../locator';
6+
import { Conda, getCondaEnvironmentsTxt } from '../../../common/environmentManagers/conda';
77
import { traceError, traceVerbose } from '../../../../logging';
8+
import { FSWatchingLocator } from './fsWatchingLocator';
9+
10+
export class CondaEnvironmentLocator extends FSWatchingLocator<BasicEnvInfo> {
11+
public constructor() {
12+
super(
13+
() => getCondaEnvironmentsTxt(),
14+
async () => PythonEnvKind.Conda,
15+
{ isFile: true },
16+
);
17+
}
818

9-
export class CondaEnvironmentLocator extends Locator<BasicEnvInfo> {
1019
// eslint-disable-next-line class-methods-use-this
11-
public async *iterEnvs(): IPythonEnvsIterator<BasicEnvInfo> {
20+
public async *doIterEnvs(): IPythonEnvsIterator<BasicEnvInfo> {
1221
const conda = await Conda.getConda();
1322
if (conda === undefined) {
1423
traceVerbose(`Couldn't locate the conda binary.`);

extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/fsWatchingLocator.ts

Lines changed: 54 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import * as fs from 'fs';
55
import * as path from 'path';
66
import { Uri } from 'vscode';
7-
import { FileChangeType } from '../../../../common/platform/fileSystemWatcher';
7+
import { FileChangeType, watchLocationForPattern } from '../../../../common/platform/fileSystemWatcher';
88
import { sleep } from '../../../../common/utils/async';
99
import { traceError, traceVerbose } from '../../../../logging';
1010
import { getEnvironmentDirFromPath } from '../../../common/commonUtils';
@@ -47,6 +47,33 @@ function checkDirWatchable(dirname: string): DirUnwatchableReason {
4747
return undefined;
4848
}
4949

50+
type LocationWatchOptions = {
51+
/**
52+
* Glob which represents basename of the executable or directory to watch.
53+
*/
54+
baseGlob?: string;
55+
/**
56+
* Time to wait before handling an environment-created event.
57+
*/
58+
delayOnCreated?: number; // milliseconds
59+
/**
60+
* Location affected by the event. If not provided, a default search location is used.
61+
*/
62+
searchLocation?: string;
63+
/**
64+
* The Python env structure to watch.
65+
*/
66+
envStructure?: PythonEnvStructure;
67+
};
68+
69+
type FileWatchOptions = {
70+
/**
71+
* If the provided root is a file instead. In this case the file is directly watched instead for
72+
* looking for python binaries inside a root.
73+
*/
74+
isFile: boolean;
75+
};
76+
5077
/**
5178
* The base for Python envs locators who watch the file system.
5279
* Most low-level locators should be using this.
@@ -63,24 +90,7 @@ export abstract class FSWatchingLocator<I = PythonEnvInfo> extends LazyResourceB
6390
* Returns the kind of environment specific to locator given the path to executable.
6491
*/
6592
private readonly getKind: (executable: string) => Promise<PythonEnvKind>,
66-
private readonly opts: {
67-
/**
68-
* Glob which represents basename of the executable or directory to watch.
69-
*/
70-
baseGlob?: string;
71-
/**
72-
* Time to wait before handling an environment-created event.
73-
*/
74-
delayOnCreated?: number; // milliseconds
75-
/**
76-
* Location affected by the event. If not provided, a default search location is used.
77-
*/
78-
searchLocation?: string;
79-
/**
80-
* The Python env structure to watch.
81-
*/
82-
envStructure?: PythonEnvStructure;
83-
} = {},
93+
private readonly creationOptions: LocationWatchOptions | FileWatchOptions = {},
8494
private readonly watcherKind: FSWatcherKind = FSWatcherKind.Global,
8595
) {
8696
super();
@@ -89,8 +99,8 @@ export abstract class FSWatchingLocator<I = PythonEnvInfo> extends LazyResourceB
8999

90100
protected async initWatchers(): Promise<void> {
91101
// Enable all workspace watchers.
92-
if (this.watcherKind === FSWatcherKind.Global) {
93-
// Do not allow global watchers for now
102+
if (this.watcherKind === FSWatcherKind.Global && !isWatchingAFile(this.creationOptions)) {
103+
// Do not allow global location watchers for now.
94104
return;
95105
}
96106

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

118131
private startWatchers(root: string): void {
132+
const opts = this.creationOptions;
133+
if (isWatchingAFile(opts)) {
134+
traceVerbose('Start watching file for changes', root);
135+
this.disposables.push(
136+
watchLocationForPattern(path.dirname(root), path.basename(root), () => {
137+
traceVerbose('Detected change in file: ', root, 'initiating a refresh');
138+
this.emitter.fire({});
139+
}),
140+
);
141+
return;
142+
}
119143
const callback = async (type: FileChangeType, executable: string) => {
120144
if (type === FileChangeType.Created) {
121-
if (this.opts.delayOnCreated !== undefined) {
145+
if (opts.delayOnCreated !== undefined) {
122146
// Note detecting kind of env depends on the file structure around the
123147
// executable, so we need to wait before attempting to detect it.
124-
await sleep(this.opts.delayOnCreated);
148+
await sleep(opts.delayOnCreated);
125149
}
126150
}
127151
// Fetching kind after deletion normally fails because the file structure around the
@@ -135,20 +159,22 @@ export abstract class FSWatchingLocator<I = PythonEnvInfo> extends LazyResourceB
135159
// |__ env
136160
// |__ bin or Scripts
137161
// |__ python <--- executable
138-
const searchLocation = Uri.file(
139-
this.opts.searchLocation ?? path.dirname(getEnvironmentDirFromPath(executable)),
140-
);
162+
const searchLocation = Uri.file(opts.searchLocation ?? path.dirname(getEnvironmentDirFromPath(executable)));
141163
traceVerbose('Fired event ', JSON.stringify({ type, kind, searchLocation }), 'from locator');
142164
this.emitter.fire({ type, kind, searchLocation });
143165
};
144166

145167
const globs = resolvePythonExeGlobs(
146-
this.opts.baseGlob,
168+
opts.baseGlob,
147169
// The structure determines which globs are returned.
148-
this.opts.envStructure,
170+
opts.envStructure,
149171
);
150172
traceVerbose('Start watching root', root, 'for globs', JSON.stringify(globs));
151173
const watchers = globs.map((g) => watchLocationForPythonBinaries(root, callback, g));
152174
this.disposables.push(...watchers);
153175
}
154176
}
177+
178+
function isWatchingAFile(options: LocationWatchOptions | FileWatchOptions): options is FileWatchOptions {
179+
return 'isFile' in options && options.isFile;
180+
}

extensions/positron-python/src/client/pythonEnvironments/common/environmentManagers/conda.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,18 @@ export async function isCondaEnvironment(interpreterPathOrEnvPath: string): Prom
156156
return false;
157157
}
158158

159+
/**
160+
* Gets path to conda's `environments.txt` file. More info https://github.com/conda/conda/issues/11845.
161+
*/
162+
export async function getCondaEnvironmentsTxt(): Promise<string[]> {
163+
const homeDir = getUserHomeDir();
164+
if (!homeDir) {
165+
return [];
166+
}
167+
const environmentsTxt = path.join(homeDir, '.conda', 'environments.txt');
168+
return [environmentsTxt];
169+
}
170+
159171
/**
160172
* Extracts version information from `conda-meta/history` near a given interpreter.
161173
* @param interpreterPath Absolute path to the interpreter

extensions/positron-python/src/client/pythonEnvironments/legacyIOC.ts

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,6 @@ function convertEnvInfo(info: PythonEnvInfo): PythonEnvironment {
8080
}
8181
@injectable()
8282
class ComponentAdapter implements IComponentAdapter {
83-
private readonly refreshing = new vscode.EventEmitter<void>();
84-
85-
private readonly refreshed = new vscode.EventEmitter<void>();
86-
8783
private readonly changed = new vscode.EventEmitter<PythonEnvironmentsChangedEvent>();
8884

8985
constructor(
@@ -135,15 +131,6 @@ class ComponentAdapter implements IComponentAdapter {
135131
});
136132
}
137133

138-
// Implements IInterpreterLocatorProgressHandler
139-
public get onRefreshing(): vscode.Event<void> {
140-
return this.refreshing.event;
141-
}
142-
143-
public get onRefreshed(): vscode.Event<void> {
144-
return this.refreshed.event;
145-
}
146-
147134
// Implements IInterpreterHelper
148135
public async getInterpreterInformation(pythonPath: string): Promise<Partial<PythonEnvironment> | undefined> {
149136
const env = await this.api.resolveEnv(pythonPath);
@@ -231,9 +218,6 @@ class ComponentAdapter implements IComponentAdapter {
231218
}
232219

233220
public getInterpreters(resource?: vscode.Uri, source?: PythonEnvSource[]): PythonEnvironment[] {
234-
// Notify locators are locating.
235-
this.refreshing.fire();
236-
237221
const query: PythonLocatorQuery = {};
238222
let roots: vscode.Uri[] = [];
239223
let wsFolder: vscode.WorkspaceFolder | undefined;
@@ -262,12 +246,7 @@ class ComponentAdapter implements IComponentAdapter {
262246
envs = envs.filter((env) => intersection(source, env.source).length > 0);
263247
}
264248

265-
const legacyEnvs = envs.map(convertEnvInfo);
266-
267-
// Notify all locators have completed locating. Note it's crucial to notify this even when getInterpretersViaAPI
268-
// fails, to ensure "Python extension loading..." text disappears.
269-
this.refreshed.fire();
270-
return legacyEnvs;
249+
return envs.map(convertEnvInfo);
271250
}
272251

273252
public async getWorkspaceVirtualEnvInterpreters(
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import * as path from 'path';
5+
import * as fs from 'fs-extra';
6+
import { assert } from 'chai';
7+
import * as sinon from 'sinon';
8+
import * as platformUtils from '../../../../../client/common/utils/platform';
9+
import { CondaEnvironmentLocator } from '../../../../../client/pythonEnvironments/base/locators/lowLevel/condaLocator';
10+
import { sleep } from '../../../../core';
11+
import { createDeferred, Deferred } from '../../../../../client/common/utils/async';
12+
import { PythonEnvsChangedEvent } from '../../../../../client/pythonEnvironments/base/watcher';
13+
import { TEST_TIMEOUT } from '../../../../constants';
14+
import { traceWarn } from '../../../../../client/logging';
15+
import { TEST_LAYOUT_ROOT } from '../../../common/commonTestConstants';
16+
17+
class CondaEnvs {
18+
private readonly condaEnvironmentsTxt;
19+
20+
constructor() {
21+
const home = platformUtils.getUserHomeDir();
22+
if (!home) {
23+
throw new Error('Home directory not found');
24+
}
25+
this.condaEnvironmentsTxt = path.join(home, '.conda', 'environments.txt');
26+
}
27+
28+
public async create(): Promise<void> {
29+
try {
30+
await fs.createFile(this.condaEnvironmentsTxt);
31+
} catch (err) {
32+
throw new Error(`Failed to create environments.txt ${this.condaEnvironmentsTxt}, Error: ${err}`);
33+
}
34+
}
35+
36+
public async update(): Promise<void> {
37+
try {
38+
await fs.writeFile(this.condaEnvironmentsTxt, 'path/to/environment');
39+
} catch (err) {
40+
throw new Error(`Failed to update environments file ${this.condaEnvironmentsTxt}, Error: ${err}`);
41+
}
42+
}
43+
44+
public async cleanUp() {
45+
try {
46+
await fs.remove(this.condaEnvironmentsTxt);
47+
} catch (err) {
48+
traceWarn(`Failed to clean up ${this.condaEnvironmentsTxt}`);
49+
}
50+
}
51+
}
52+
53+
suite('Conda Env Watcher', async () => {
54+
let locator: CondaEnvironmentLocator;
55+
let condaEnvsTxt: CondaEnvs;
56+
57+
async function waitForChangeToBeDetected(deferred: Deferred<void>) {
58+
const timeout = setTimeout(() => {
59+
clearTimeout(timeout);
60+
deferred.reject(new Error('Environment not detected'));
61+
}, TEST_TIMEOUT);
62+
await deferred.promise;
63+
}
64+
65+
setup(async () => {
66+
sinon.stub(platformUtils, 'getUserHomeDir').returns(TEST_LAYOUT_ROOT);
67+
condaEnvsTxt = new CondaEnvs();
68+
await condaEnvsTxt.cleanUp();
69+
});
70+
71+
async function setupLocator(onChanged: (e: PythonEnvsChangedEvent) => Promise<void>) {
72+
locator = new CondaEnvironmentLocator();
73+
// Wait for watchers to get ready
74+
await sleep(1000);
75+
locator.onChanged(onChanged);
76+
}
77+
78+
teardown(async () => {
79+
await condaEnvsTxt.cleanUp();
80+
await locator.dispose();
81+
sinon.restore();
82+
});
83+
84+
test('Fires when conda `environments.txt` file is created', async () => {
85+
let actualEvent: PythonEnvsChangedEvent;
86+
const deferred = createDeferred<void>();
87+
const expectedEvent = {};
88+
await setupLocator(async (e) => {
89+
deferred.resolve();
90+
actualEvent = e;
91+
});
92+
93+
await condaEnvsTxt.create();
94+
await waitForChangeToBeDetected(deferred);
95+
96+
assert.deepEqual(actualEvent!, expectedEvent, 'Unexpected event emitted');
97+
});
98+
99+
test('Fires when conda `environments.txt` file is updated', async () => {
100+
let actualEvent: PythonEnvsChangedEvent;
101+
const deferred = createDeferred<void>();
102+
const expectedEvent = {};
103+
await condaEnvsTxt.create();
104+
await setupLocator(async (e) => {
105+
deferred.resolve();
106+
actualEvent = e;
107+
});
108+
109+
await condaEnvsTxt.update();
110+
await waitForChangeToBeDetected(deferred);
111+
112+
assert.deepEqual(actualEvent!, expectedEvent, 'Unexpected event emitted');
113+
});
114+
});

0 commit comments

Comments
 (0)