Skip to content

Commit ed02170

Browse files
authored
Merge branch 'main' into fix/issue-1877-uv-managed-python-global
2 parents 6efcfb0 + 7b69d86 commit ed02170

5 files changed

Lines changed: 252 additions & 9 deletions

File tree

‎src/managers/builtin/main.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ export async function registerSystemPythonFeatures(
2828
venvManager.watcherRefresh();
2929
});
3030
const activationWatcher = createFileSystemWatcher('{**/activate}', false, false, false);
31+
// Deleting a folder is reported for the folder only, not for the files in it, so deleting a
32+
// virtual environment folder never matches the activate script pattern above.
33+
const deletionWatcher = createFileSystemWatcher('**/*', true, true, false);
3134
disposables.push(
3235
activationWatcher,
3336
activationWatcher.onDidCreate(() => {
@@ -39,6 +42,12 @@ export async function registerSystemPythonFeatures(
3942
activationWatcher.onDidDelete(() => {
4043
venvDebouncedRefresh.trigger();
4144
}),
45+
deletionWatcher,
46+
deletionWatcher.onDidDelete((uri) => {
47+
if (venvManager.hasEnvironmentAt(uri.fsPath)) {
48+
venvDebouncedRefresh.trigger();
49+
}
50+
}),
4251
onDidDeleteFiles(() => {
4352
venvDebouncedRefresh.trigger();
4453
}),

‎src/managers/builtin/venvManager.ts‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import {
5353

5454
export class VenvManager implements EnvironmentManager {
5555
private collection: PythonEnvironment[] = [];
56+
private environmentFolders: { collection: PythonEnvironment[]; length: number; folders: Set<string> } | undefined;
5657
private readonly fsPathToEnv: Map<string, PythonEnvironment> = new Map();
5758
private globalEnv: PythonEnvironment | undefined;
5859
private skipWatcherRefresh = false;
@@ -314,6 +315,31 @@ export class VenvManager implements EnvironmentManager {
314315
return this.internalRefresh(undefined, true, VenvManagerStrings.venvRefreshing);
315316
}
316317

318+
/**
319+
* Returns true when a known virtual environment is at, or inside, the given path.
320+
*
321+
* Called for every deletion in the workspace, so it is a single set lookup: the set holds every
322+
* environment prefix and its parent folders, and is rebuilt when the collection changes.
323+
*
324+
* @param fsPath A file system path, for example of a deleted folder.
325+
*/
326+
hasEnvironmentAt(fsPath: string): boolean {
327+
const cache = this.environmentFolders;
328+
if (cache?.collection !== this.collection || cache.length !== this.collection.length) {
329+
const folders = new Set<string>();
330+
for (const env of this.collection) {
331+
let folder = env.sysPrefix ? path.resolve(env.sysPrefix) : undefined;
332+
while (folder && !folders.has(normalizePath(folder))) {
333+
folders.add(normalizePath(folder));
334+
const parent = path.dirname(folder);
335+
folder = parent !== folder ? parent : undefined;
336+
}
337+
}
338+
this.environmentFolders = { collection: this.collection, length: this.collection.length, folders };
339+
}
340+
return this.environmentFolders!.folders.has(normalizePath(path.resolve(fsPath)));
341+
}
342+
317343
private async internalRefresh(
318344
scope: RefreshEnvironmentsScope,
319345
hardRefresh: boolean,
@@ -575,6 +601,7 @@ export class VenvManager implements EnvironmentManager {
575601

576602
/**
577603
* Loads and maps Python environments to their corresponding project paths in the workspace. about O(p × e) where p = projects.len and e = environments.len
604+
* Skips unresolvable selections without preventing other projects from restoring their environments.
578605
*/
579606
private async loadEnvMap() {
580607
const globals = await this.baseManager.getEnvironments('global');
@@ -609,7 +636,7 @@ export class VenvManager implements EnvironmentManager {
609636
foundEnv = resolved;
610637
} else {
611638
this.log.error(`Failed to resolve python environment: ${env}`);
612-
return;
639+
continue;
613640
}
614641
}
615642
// Given found env, add it to the map and fire the event if needed.

‎src/test/integration/environmentFixture.ts‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const OWNERSHIP_FILE_NAME = '.python-envs-test-owner.json';
1818
const COMMAND_TIMEOUT_MS = 180_000;
1919
const DISCOVERY_TIMEOUT_MS = 60_000;
2020
const API_REMOVAL_SETTLE_TIMEOUT_MS = 10_000;
21+
const CONDA_FIXTURE_PYTHON_VERSION = '3.12';
2122

2223
export interface EnvironmentFixtureProvider {
2324
readonly environmentDirectory: string;
@@ -312,16 +313,12 @@ export function createCondaFixtureProvider(): EnvironmentFixtureProvider {
312313
environmentDirectory: '.conda',
313314
managerId: CONDA_MANAGER_ID,
314315
create: async (_api, prefix) => {
315-
const version = await runFixtureCommand('python', [
316-
'-c',
317-
'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")',
318-
]);
319316
await runFixtureCommand(await getCondaExecutable(), [
320317
'create',
321318
'--yes',
322319
'--prefix',
323320
prefix.fsPath,
324-
`python=${version.stdout.trim()}`,
321+
`python=${CONDA_FIXTURE_PYTHON_VERSION}`,
325322
]);
326323
return undefined;
327324
},

‎src/test/managers/builtin/main.unit.test.ts‎

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
// Licensed under the MIT License.
33

44
import * as assert from 'assert';
5+
import * as path from 'path';
56
import * as sinon from 'sinon';
67
import { Disposable, EventEmitter, LogOutputChannel, Uri } from 'vscode';
7-
import { PythonEnvironmentApi } from '../../../api';
8+
import { PythonEnvironment, PythonEnvironmentApi } from '../../../api';
89
import * as workspaceApis from '../../../common/workspace.apis';
910
import * as extensionApi from '../../../extensionApi';
1011
import { registerSystemPythonFeatures } from '../../../managers/builtin/main';
@@ -61,7 +62,9 @@ suite('registerSystemPythonFeatures - activation watcher', () => {
6162

6263
test('activation watcher is created with change events enabled and triggers refresh on change', async () => {
6364
const mockWatcher = createMockWatcher();
64-
createFileSystemWatcherStub.returns(mockWatcher);
65+
const deletionWatcher = createMockWatcher();
66+
createFileSystemWatcherStub.withArgs('{**/activate}').returns(mockWatcher);
67+
createFileSystemWatcherStub.withArgs('**/*').returns(deletionWatcher);
6568

6669
const disposables: Disposable[] = [];
6770
const nativeFinder = {} as NativePythonFinder;
@@ -76,7 +79,7 @@ suite('registerSystemPythonFeatures - activation watcher', () => {
7679
await registerSystemPythonFeatures(nativeFinder, disposables, log, envManager);
7780

7881
// Assertion A: Watcher is created with [false, false, false]
79-
assert.strictEqual(createFileSystemWatcherStub.callCount, 1);
82+
assert.strictEqual(createFileSystemWatcherStub.callCount, 2);
8083
assert.deepStrictEqual(createFileSystemWatcherStub.firstCall.args, ['{**/activate}', false, false, false]);
8184

8285
// Assertion B & C: CHANGE event triggers debounced watcherRefresh
@@ -112,4 +115,83 @@ suite('registerSystemPythonFeatures - activation watcher', () => {
112115
mockWatcher._deleteEmitter.dispose();
113116
disposables.forEach((d) => d.dispose());
114117
});
118+
119+
test('deleting a folder that holds a known venv triggers refresh', async () => {
120+
const deletionWatcher = createMockWatcher();
121+
createFileSystemWatcherStub.withArgs('{**/activate}').returns(createMockWatcher());
122+
createFileSystemWatcherStub.withArgs('**/*').returns(deletionWatcher);
123+
const venvFolder = Uri.file(path.resolve('workspace', '.venv'));
124+
sandbox
125+
.stub(VenvManager.prototype, 'hasEnvironmentAt')
126+
.callsFake((fsPath: string) => fsPath === venvFolder.fsPath);
127+
128+
const disposables: Disposable[] = [];
129+
await registerSystemPythonFeatures(
130+
{} as NativePythonFinder,
131+
disposables,
132+
{} as LogOutputChannel,
133+
{} as SysPythonManager,
134+
);
135+
136+
// Only deletions are watched: the folder is reported, not the files in it.
137+
assert.ok(createFileSystemWatcherStub.calledWithExactly('**/*', true, true, false));
138+
139+
deletionWatcher._deleteEmitter.fire(Uri.file(path.resolve('workspace', 'notes.txt')));
140+
await clock.tickAsync(600);
141+
assert.strictEqual(watcherRefreshStub.callCount, 0);
142+
143+
deletionWatcher._deleteEmitter.fire(venvFolder);
144+
await clock.tickAsync(600);
145+
assert.strictEqual(watcherRefreshStub.callCount, 1);
146+
147+
disposables.forEach((d) => d.dispose());
148+
});
149+
});
150+
151+
suite('VenvManager.hasEnvironmentAt', () => {
152+
const project = path.resolve('venv-delete-project');
153+
const venvPrefix = path.join(project, '.venv');
154+
155+
function createManager(): VenvManager {
156+
const manager = new VenvManager(
157+
{} as NativePythonFinder,
158+
{} as PythonEnvironmentApi,
159+
{} as SysPythonManager,
160+
{} as LogOutputChannel,
161+
);
162+
(manager as unknown as { collection: PythonEnvironment[] }).collection = [
163+
{ sysPrefix: venvPrefix } as PythonEnvironment,
164+
];
165+
return manager;
166+
}
167+
168+
test('is true for the venv folder and its parent folders', () => {
169+
const manager = createManager();
170+
171+
assert.strictEqual(manager.hasEnvironmentAt(venvPrefix), true);
172+
assert.strictEqual(manager.hasEnvironmentAt(project), true);
173+
});
174+
175+
test('is false for unrelated paths and paths inside the venv', () => {
176+
const manager = createManager();
177+
178+
assert.strictEqual(manager.hasEnvironmentAt(path.join(project, 'src')), false);
179+
assert.strictEqual(manager.hasEnvironmentAt(path.join(venvPrefix, 'Lib', 'site-packages', 'pkg')), false);
180+
assert.strictEqual(manager.hasEnvironmentAt(path.resolve('venv-delete-project-other')), false);
181+
assert.strictEqual(manager.hasEnvironmentAt(`${venvPrefix}2`), false);
182+
});
183+
184+
test('follows changes to the known environments', () => {
185+
const manager = createManager();
186+
const otherPrefix = path.resolve('venv-delete-other', '.venv');
187+
assert.strictEqual(manager.hasEnvironmentAt(otherPrefix), false);
188+
189+
const state = manager as unknown as { collection: PythonEnvironment[] };
190+
state.collection.push({ sysPrefix: otherPrefix } as PythonEnvironment);
191+
assert.strictEqual(manager.hasEnvironmentAt(otherPrefix), true);
192+
193+
state.collection = [];
194+
assert.strictEqual(manager.hasEnvironmentAt(venvPrefix), false);
195+
assert.strictEqual(manager.hasEnvironmentAt(otherPrefix), false);
196+
});
115197
});
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import * as assert from 'assert';
5+
import * as path from 'path';
6+
import * as sinon from 'sinon';
7+
import * as typemoq from 'typemoq';
8+
import { CancellationTokenSource, LogOutputChannel, Uri } from 'vscode';
9+
import {
10+
DidChangeEnvironmentEventArgs,
11+
EnvironmentManager,
12+
PythonEnvironmentApi,
13+
PythonProject,
14+
} from '../../../api';
15+
import { VENV_MANAGER_ID } from '../../../common/constants';
16+
import * as windowApis from '../../../common/window.apis';
17+
import { VenvManager } from '../../../managers/builtin/venvManager';
18+
import * as venvUtils from '../../../managers/builtin/venvUtils';
19+
import { NativePythonFinder } from '../../../managers/common/nativePythonFinder';
20+
import { createMockPythonEnvironment } from '../../mocks/pythonEnvironment';
21+
22+
suite('VenvManager - project selection recovery', () => {
23+
let sandbox: sinon.SinonSandbox;
24+
let cancellation: CancellationTokenSource;
25+
26+
setup(() => {
27+
sandbox = sinon.createSandbox();
28+
cancellation = new CancellationTokenSource();
29+
sandbox.stub(windowApis, 'withProgress').callsFake(async (_options, task) =>
30+
task({ report: () => {} }, cancellation.token),
31+
);
32+
sandbox.stub(venvUtils, 'getVenvForGlobal').resolves(undefined);
33+
});
34+
35+
teardown(() => {
36+
sandbox.restore();
37+
cancellation.dispose();
38+
});
39+
40+
for (const operation of ['initialize', 'refresh'] as const) {
41+
for (const brokenIndex of [0, 1]) {
42+
test(`${operation} restores other projects when saved selection ${brokenIndex + 1} cannot resolve`, async () => {
43+
const projects: PythonProject[] = ['a', 'b', 'c'].map((name) => ({
44+
name,
45+
uri: Uri.file(path.resolve('selection-recovery', `project-${name}`)),
46+
}));
47+
const environments = projects.map((project) =>
48+
createMockPythonEnvironment({
49+
name: project.name,
50+
envPath: path.resolve(
51+
'selection-recovery',
52+
`external-${project.name}`,
53+
process.platform === 'win32' ? 'Scripts' : 'bin',
54+
'python',
55+
),
56+
managerId: VENV_MANAGER_ID,
57+
}),
58+
);
59+
const fallback = createMockPythonEnvironment({
60+
name: 'global',
61+
envPath: path.resolve('selection-recovery', 'global', 'python'),
62+
});
63+
const api = typemoq.Mock.ofType<PythonEnvironmentApi>();
64+
api.setup((a) => a.getPythonProjects()).returns(() => projects);
65+
api.setup((a) => a.getPythonProject(typemoq.It.isAny())).returns((uri: Uri) =>
66+
projects.find((project) => project.uri.fsPath === uri.fsPath),
67+
);
68+
const baseManager = typemoq.Mock.ofType<EnvironmentManager>();
69+
baseManager.setup((m) => m.getEnvironments('global')).returns(async () => [fallback]);
70+
const log = typemoq.Mock.ofType<LogOutputChannel>();
71+
const manager = new VenvManager(
72+
typemoq.Mock.ofType<NativePythonFinder>().object,
73+
api.object,
74+
baseManager.object,
75+
log.object,
76+
);
77+
sandbox.stub(venvUtils, 'findVirtualEnvironments').callsFake(async () => []);
78+
sandbox.stub(venvUtils, 'getVenvForWorkspace').callsFake(async (fsPath) => {
79+
const index = projects.findIndex((project) => project.uri.fsPath === fsPath);
80+
return environments[index]?.environmentPath.fsPath;
81+
});
82+
const resolve = sandbox
83+
.stub(venvUtils, 'resolveVenvPythonEnvironmentPath')
84+
.callsFake(async (fsPath) => environments.find((env) => env.environmentPath.fsPath === fsPath));
85+
const events: DidChangeEnvironmentEventArgs[] = [];
86+
const subscription = manager.onDidChangeEnvironment((event) => events.push(event));
87+
try {
88+
if (operation === 'refresh') {
89+
await manager.initialize();
90+
for (const [index, project] of projects.entries()) {
91+
assert.strictEqual(await manager.get(project.uri), environments[index]);
92+
}
93+
events.length = 0;
94+
}
95+
96+
const brokenPath = environments[brokenIndex].environmentPath.fsPath;
97+
resolve.withArgs(brokenPath).resolves(undefined);
98+
if (operation === 'initialize') {
99+
await manager.initialize();
100+
} else {
101+
await manager.refresh(undefined);
102+
}
103+
104+
for (const [index, project] of projects.entries()) {
105+
if (index !== brokenIndex) {
106+
assert.strictEqual(
107+
(await manager.get(project.uri))?.environmentPath.fsPath,
108+
environments[index].environmentPath.fsPath,
109+
`Project ${project.name} must retain its selected interpreter`,
110+
);
111+
}
112+
}
113+
assert.deepStrictEqual(
114+
events.map((event) => event.uri?.fsPath),
115+
projects.filter((_, index) => index !== brokenIndex).map((project) => project.uri.fsPath),
116+
'Healthy projects must still publish their restored selections',
117+
);
118+
log.verify(
119+
(l) => l.error(`Failed to resolve python environment: ${brokenPath}`),
120+
typemoq.Times.once(),
121+
);
122+
} finally {
123+
subscription.dispose();
124+
}
125+
});
126+
}
127+
}
128+
});

0 commit comments

Comments
 (0)