Skip to content

Commit ba93146

Browse files
authored
feat: enhance getVenvForWorkspace to prioritize persisted selections (#1397)
`getVenvForWorkspace()` in `src/managers/builtin/venvUtils.ts](src/managers/builtin/venvUtils.ts#L64-L65` unconditionally returns `process.env.VIRTUAL_ENV` if set, **ignoring the user's explicitly saved workspace selection**. This PR switches that ordering fixes microsoft/vscode-python#25867
1 parent f2a697b commit ba93146

File tree

2 files changed

+314
-12
lines changed

2 files changed

+314
-12
lines changed

src/managers/builtin/venvUtils.ts

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -61,23 +61,32 @@ export async function clearVenvCache(): Promise<void> {
6161
}
6262

6363
export async function getVenvForWorkspace(fsPath: string): Promise<string | undefined> {
64-
if (process.env.VIRTUAL_ENV) {
65-
return process.env.VIRTUAL_ENV;
66-
}
67-
68-
const state = await getWorkspacePersistentState();
69-
const data: { [key: string]: string } | undefined = await state.get(VENV_WORKSPACE_KEY);
70-
if (data) {
71-
try {
64+
// Check persisted user selection first — this should always take priority
65+
// over process.env.VIRTUAL_ENV so that explicit selections survive reload.
66+
try {
67+
const state = await getWorkspacePersistentState();
68+
const data: { [key: string]: string } | undefined = await state.get(VENV_WORKSPACE_KEY);
69+
if (data) {
7270
const envPath = data[fsPath];
73-
if (await fsapi.pathExists(envPath)) {
71+
if (envPath && (await fsapi.pathExists(envPath))) {
7472
return envPath;
7573
}
76-
setVenvForWorkspace(fsPath, undefined);
77-
} catch {
78-
return undefined;
74+
if (envPath) {
75+
await setVenvForWorkspace(fsPath, undefined);
76+
}
77+
}
78+
} catch {
79+
// fall through to VIRTUAL_ENV check
80+
}
81+
82+
// Fall back to VIRTUAL_ENV only if it points to a path inside this workspace.
83+
if (process.env.VIRTUAL_ENV) {
84+
const rel = path.relative(fsPath, process.env.VIRTUAL_ENV);
85+
if (rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))) {
86+
return process.env.VIRTUAL_ENV;
7987
}
8088
}
89+
8190
return undefined;
8291
}
8392

Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
import * as assert from 'assert';
2+
import * as fse from 'fs-extra';
3+
import * as os from 'os';
4+
import * as path from 'path';
5+
import * as sinon from 'sinon';
6+
import * as persistentState from '../../../common/persistentState';
7+
import { getVenvForWorkspace, VENV_WORKSPACE_KEY } from '../../../managers/builtin/venvUtils';
8+
9+
/**
10+
* Helper that replicates the VIRTUAL_ENV subpath check from getVenvForWorkspace
11+
* using a specific path module, allowing cross-platform verification.
12+
*/
13+
function isVenvInsideWorkspace(
14+
fsPath: string,
15+
virtualEnv: string,
16+
pathModule: typeof path.posix | typeof path.win32,
17+
): boolean {
18+
const rel = pathModule.relative(fsPath, virtualEnv);
19+
return rel === '' || (!rel.startsWith('..') && !pathModule.isAbsolute(rel));
20+
}
21+
22+
suite('VIRTUAL_ENV subpath check - cross-platform', () => {
23+
suite('POSIX paths', () => {
24+
const p = path.posix;
25+
26+
test('venv inside workspace should match', () => {
27+
assert.strictEqual(isVenvInsideWorkspace('/proj/app', '/proj/app/.venv', p), true);
28+
});
29+
30+
test('venv deeply nested inside workspace should match', () => {
31+
assert.strictEqual(isVenvInsideWorkspace('/proj/app', '/proj/app/sub/dir/.venv', p), true);
32+
});
33+
34+
test('venv equal to workspace should match', () => {
35+
assert.strictEqual(isVenvInsideWorkspace('/proj/app', '/proj/app', p), true);
36+
});
37+
38+
test('sibling with shared prefix should NOT match', () => {
39+
assert.strictEqual(isVenvInsideWorkspace('/proj/app', '/proj/app2/.venv', p), false);
40+
});
41+
42+
test('venv in completely different location should NOT match', () => {
43+
assert.strictEqual(isVenvInsideWorkspace('/proj/app', '/other/place/.venv', p), false);
44+
});
45+
46+
test('parent directory should NOT match', () => {
47+
assert.strictEqual(isVenvInsideWorkspace('/proj/app', '/proj/.venv', p), false);
48+
});
49+
});
50+
51+
suite('Windows paths', () => {
52+
const p = path.win32;
53+
54+
test('venv inside workspace should match', () => {
55+
assert.strictEqual(isVenvInsideWorkspace('C:\\proj\\app', 'C:\\proj\\app\\.venv', p), true);
56+
});
57+
58+
test('venv deeply nested inside workspace should match', () => {
59+
assert.strictEqual(isVenvInsideWorkspace('C:\\proj\\app', 'C:\\proj\\app\\sub\\dir\\.venv', p), true);
60+
});
61+
62+
test('venv equal to workspace should match', () => {
63+
assert.strictEqual(isVenvInsideWorkspace('C:\\proj\\app', 'C:\\proj\\app', p), true);
64+
});
65+
66+
test('sibling with shared prefix should NOT match', () => {
67+
assert.strictEqual(isVenvInsideWorkspace('C:\\proj\\app', 'C:\\proj\\app2\\.venv', p), false);
68+
});
69+
70+
test('venv on a different drive should NOT match', () => {
71+
assert.strictEqual(isVenvInsideWorkspace('C:\\proj\\app', 'D:\\proj\\app\\.venv', p), false);
72+
});
73+
74+
test('parent directory should NOT match', () => {
75+
assert.strictEqual(isVenvInsideWorkspace('C:\\proj\\app', 'C:\\proj\\.venv', p), false);
76+
});
77+
78+
test('case-insensitive drive letters should match', () => {
79+
// path.win32.relative handles case-insensitive drive letters
80+
assert.strictEqual(isVenvInsideWorkspace('c:\\proj\\app', 'C:\\proj\\app\\.venv', p), true);
81+
});
82+
83+
test('UNC path inside workspace should match', () => {
84+
assert.strictEqual(
85+
isVenvInsideWorkspace('\\\\server\\share\\proj', '\\\\server\\share\\proj\\.venv', p),
86+
true,
87+
);
88+
});
89+
90+
test('UNC path sibling should NOT match', () => {
91+
assert.strictEqual(
92+
isVenvInsideWorkspace('\\\\server\\share\\proj', '\\\\server\\share\\proj2\\.venv', p),
93+
false,
94+
);
95+
});
96+
});
97+
});
98+
99+
suite('getVenvForWorkspace', () => {
100+
let mockState: {
101+
get: sinon.SinonStub;
102+
set: sinon.SinonStub;
103+
clear: sinon.SinonStub;
104+
};
105+
let getWorkspacePersistentStateStub: sinon.SinonStub;
106+
let originalVirtualEnv: string | undefined;
107+
let tmpDir: string;
108+
109+
setup(async () => {
110+
originalVirtualEnv = process.env.VIRTUAL_ENV;
111+
delete process.env.VIRTUAL_ENV;
112+
113+
tmpDir = path.join(os.tmpdir(), `venv-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
114+
await fse.ensureDir(tmpDir);
115+
116+
mockState = {
117+
get: sinon.stub(),
118+
set: sinon.stub(),
119+
clear: sinon.stub(),
120+
};
121+
getWorkspacePersistentStateStub = sinon.stub(persistentState, 'getWorkspacePersistentState');
122+
getWorkspacePersistentStateStub.resolves(mockState);
123+
});
124+
125+
teardown(async () => {
126+
if (originalVirtualEnv !== undefined) {
127+
process.env.VIRTUAL_ENV = originalVirtualEnv;
128+
} else {
129+
delete process.env.VIRTUAL_ENV;
130+
}
131+
sinon.restore();
132+
await fse.remove(tmpDir);
133+
});
134+
135+
test('should return persisted selection when available', async () => {
136+
const workspacePath = path.join(tmpDir, 'projectA');
137+
const venvPath = path.join(workspacePath, '.venv');
138+
await fse.ensureDir(venvPath);
139+
140+
mockState.get.withArgs(VENV_WORKSPACE_KEY).resolves({ [workspacePath]: venvPath });
141+
142+
const result = await getVenvForWorkspace(workspacePath);
143+
assert.strictEqual(result, venvPath, 'Should return persisted venv path');
144+
});
145+
146+
test('should return persisted selection even when VIRTUAL_ENV is set', async () => {
147+
const projectA = path.join(tmpDir, 'projectA');
148+
const projectB = path.join(tmpDir, 'projectB');
149+
const persistedVenv = path.join(projectA, '.venv');
150+
const otherVenv = path.join(projectB, '.venv');
151+
await fse.ensureDir(persistedVenv);
152+
await fse.ensureDir(otherVenv);
153+
process.env.VIRTUAL_ENV = otherVenv;
154+
155+
mockState.get.withArgs(VENV_WORKSPACE_KEY).resolves({ [projectA]: persistedVenv });
156+
157+
const result = await getVenvForWorkspace(projectA);
158+
assert.strictEqual(result, persistedVenv, 'Persisted selection should take priority over VIRTUAL_ENV');
159+
});
160+
161+
test('should fall back to VIRTUAL_ENV when no persisted selection and venv is inside workspace', async () => {
162+
const workspacePath = path.join(tmpDir, 'projectA');
163+
const virtualEnvPath = path.join(workspacePath, '.venv');
164+
await fse.ensureDir(virtualEnvPath);
165+
process.env.VIRTUAL_ENV = virtualEnvPath;
166+
167+
mockState.get.withArgs(VENV_WORKSPACE_KEY).resolves(undefined);
168+
169+
const result = await getVenvForWorkspace(workspacePath);
170+
assert.strictEqual(result, virtualEnvPath, 'Should use VIRTUAL_ENV when it is inside the workspace');
171+
});
172+
173+
test('should NOT use VIRTUAL_ENV when it points to a different project', async () => {
174+
const projectA = path.join(tmpDir, 'projectA');
175+
const projectB = path.join(tmpDir, 'projectB');
176+
const otherVenv = path.join(projectB, '.venv');
177+
await fse.ensureDir(projectA);
178+
await fse.ensureDir(otherVenv);
179+
process.env.VIRTUAL_ENV = otherVenv;
180+
181+
mockState.get.withArgs(VENV_WORKSPACE_KEY).resolves(undefined);
182+
183+
const result = await getVenvForWorkspace(projectA);
184+
assert.strictEqual(result, undefined, 'Should NOT use VIRTUAL_ENV from a different project');
185+
});
186+
187+
test('should clear stale persisted path when venv no longer exists', async () => {
188+
const workspacePath = path.join(tmpDir, 'projectA');
189+
const staleVenv = path.join(workspacePath, '.venv-old');
190+
await fse.ensureDir(workspacePath);
191+
// Note: staleVenv directory does NOT exist on disk
192+
193+
mockState.get.withArgs(VENV_WORKSPACE_KEY).resolves({ [workspacePath]: staleVenv });
194+
195+
const result = await getVenvForWorkspace(workspacePath);
196+
197+
assert.strictEqual(result, undefined, 'Should return undefined for stale path');
198+
assert.ok(mockState.set.called, 'Should clear the stale entry from persistent state');
199+
const setArgs = mockState.set.firstCall.args;
200+
assert.strictEqual(setArgs[0], VENV_WORKSPACE_KEY, 'Should update the correct key');
201+
assert.strictEqual(setArgs[1][workspacePath], undefined, 'Should have removed the stale workspace entry');
202+
});
203+
204+
test('should return undefined when no persisted selection and no VIRTUAL_ENV', async () => {
205+
const workspacePath = path.join(tmpDir, 'projectA');
206+
await fse.ensureDir(workspacePath);
207+
208+
mockState.get.withArgs(VENV_WORKSPACE_KEY).resolves(undefined);
209+
210+
const result = await getVenvForWorkspace(workspacePath);
211+
assert.strictEqual(result, undefined, 'Should return undefined when nothing is available');
212+
});
213+
214+
test('should return undefined when persisted data has no entry for this workspace', async () => {
215+
const projectA = path.join(tmpDir, 'projectA');
216+
const projectB = path.join(tmpDir, 'projectB');
217+
await fse.ensureDir(projectA);
218+
219+
mockState.get.withArgs(VENV_WORKSPACE_KEY).resolves({ [projectB]: '/some/path' });
220+
221+
const result = await getVenvForWorkspace(projectA);
222+
assert.strictEqual(result, undefined, 'Should return undefined when no entry for this workspace');
223+
});
224+
225+
test('should fall back to VIRTUAL_ENV when data access throws inside try block', async () => {
226+
const workspacePath = path.join(tmpDir, 'projectA');
227+
const virtualEnvPath = path.join(workspacePath, '.venv');
228+
await fse.ensureDir(virtualEnvPath);
229+
process.env.VIRTUAL_ENV = virtualEnvPath;
230+
231+
// Return data object with a getter that throws when accessing the workspace key
232+
const badData: Record<string, string> = {};
233+
Object.defineProperty(badData, workspacePath, {
234+
get() {
235+
throw new Error('corrupted data');
236+
},
237+
enumerable: true,
238+
configurable: true,
239+
});
240+
mockState.get.withArgs(VENV_WORKSPACE_KEY).resolves(badData);
241+
242+
const result = await getVenvForWorkspace(workspacePath);
243+
assert.strictEqual(result, virtualEnvPath, 'Should fall back to VIRTUAL_ENV when try block throws');
244+
});
245+
246+
test('should not clear state when no envPath exists for the workspace key', async () => {
247+
const workspacePath = path.join(tmpDir, 'projectA');
248+
await fse.ensureDir(workspacePath);
249+
250+
mockState.get.withArgs(VENV_WORKSPACE_KEY).resolves({ other: '/some/path' });
251+
252+
await getVenvForWorkspace(workspacePath);
253+
254+
assert.ok(!mockState.set.called, 'Should not call set when there is no stale entry to clear');
255+
});
256+
257+
test('should fall back to VIRTUAL_ENV when state.get rejects', async () => {
258+
const workspacePath = path.join(tmpDir, 'projectA');
259+
const virtualEnvPath = path.join(workspacePath, '.venv');
260+
await fse.ensureDir(virtualEnvPath);
261+
process.env.VIRTUAL_ENV = virtualEnvPath;
262+
263+
mockState.get.withArgs(VENV_WORKSPACE_KEY).rejects(new Error('storage corrupted'));
264+
265+
const result = await getVenvForWorkspace(workspacePath);
266+
assert.strictEqual(result, virtualEnvPath, 'Should fall back to VIRTUAL_ENV when state.get rejects');
267+
});
268+
269+
test('should fall back to VIRTUAL_ENV when getWorkspacePersistentState rejects', async () => {
270+
const workspacePath = path.join(tmpDir, 'projectA');
271+
const virtualEnvPath = path.join(workspacePath, '.venv');
272+
await fse.ensureDir(virtualEnvPath);
273+
process.env.VIRTUAL_ENV = virtualEnvPath;
274+
275+
getWorkspacePersistentStateStub.rejects(new Error('persistent state unavailable'));
276+
277+
const result = await getVenvForWorkspace(workspacePath);
278+
assert.strictEqual(result, virtualEnvPath, 'Should fall back to VIRTUAL_ENV when persistent state fails');
279+
});
280+
281+
test('should NOT use VIRTUAL_ENV for sibling path with shared prefix', async () => {
282+
const projectA = path.join(tmpDir, 'app');
283+
const siblingVenv = path.join(tmpDir, 'app2', '.venv');
284+
await fse.ensureDir(projectA);
285+
await fse.ensureDir(siblingVenv);
286+
process.env.VIRTUAL_ENV = siblingVenv;
287+
288+
mockState.get.withArgs(VENV_WORKSPACE_KEY).resolves(undefined);
289+
290+
const result = await getVenvForWorkspace(projectA);
291+
assert.strictEqual(result, undefined, 'Should NOT match sibling path with shared prefix');
292+
});
293+
});

0 commit comments

Comments
 (0)