Skip to content

Commit 69d9624

Browse files
conda: keep inferred local environments scoped to their project (#1862)
## Summary Fixes #1861. Conda's automatic project selection currently assigns the sole project-local environment to every project without a saved or inherited selection, even when that environment belongs to another project. The multiple-environment branch already checks ownership. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ab8e95f commit 69d9624

2 files changed

Lines changed: 216 additions & 14 deletions

File tree

‎src/managers/conda/condaEnvManager.ts‎

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -551,20 +551,13 @@ export class CondaEnvManager implements EnvironmentManager, Disposable {
551551
}
552552
}
553553
} else {
554-
// If there is not an environment already assigned by user to this project
555-
// then see if there is one in the collection
556-
if (pathSorted.length === 1) {
557-
this.fsPathToEnv.set(normalizedPath, pathSorted[0]);
558-
} else {
559-
// If there is more than one environment then we need to check if the project
560-
// is a subfolder of one of the environments
561-
const found = pathSorted.find((e) => {
562-
const t = this.api.getPythonProject(e.environmentPath)?.uri.fsPath;
563-
return t && normalizePath(t) === normalizedPath;
564-
});
565-
if (found) {
566-
this.fsPathToEnv.set(normalizedPath, found);
567-
}
554+
// Infer a local environment only for its owning project, even when just one was found.
555+
const found = pathSorted.find((e) => {
556+
const t = this.api.getPythonProject(e.environmentPath)?.uri.fsPath;
557+
return t && normalizePath(t) === normalizedPath;
558+
});
559+
if (found) {
560+
this.fsPathToEnv.set(normalizedPath, found);
568561
}
569562
}
570563
}
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
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, WorkspaceConfiguration } from 'vscode';
9+
import { PythonEnvironment, PythonEnvironmentApi, PythonProject } from '../../../api';
10+
import * as telemetry from '../../../common/telemetry/sender';
11+
import { isSameOrParentPath } from '../../../common/utils/pathUtils';
12+
import * as windowApis from '../../../common/window.apis';
13+
import * as workspaceApis from '../../../common/workspace.apis';
14+
import { PythonProjectManagerImpl } from '../../../features/projectManager';
15+
import { NativePythonFinder } from '../../../managers/common/nativePythonFinder';
16+
import { CondaEnvManager } from '../../../managers/conda/condaEnvManager';
17+
import * as sourcing from '../../../managers/conda/condaSourcingUtils';
18+
import * as condaUtils from '../../../managers/conda/condaUtils';
19+
import { makeMockCondaEnvironment } from '../../mocks/pythonEnvironment';
20+
21+
interface IsolationScenario {
22+
name: string;
23+
hasBase: boolean;
24+
otherLocal?: boolean;
25+
explicit?: 'shared' | 'external';
26+
nesting?: 'owner-is-child' | 'other-is-child';
27+
reverseProjectOrder?: boolean;
28+
}
29+
30+
const scenarios: IsolationScenario[] = [
31+
{ name: 'no-base', hasBase: false },
32+
{ name: 'base', hasBase: true, reverseProjectOrder: true },
33+
{ name: 'two-local', hasBase: true, otherLocal: true },
34+
{ name: 'explicit-shared', hasBase: true, explicit: 'shared' },
35+
{ name: 'explicit-external', hasBase: true, explicit: 'external', reverseProjectOrder: true },
36+
{ name: 'nested-parent', hasBase: true, nesting: 'owner-is-child' },
37+
{ name: 'nested-child', hasBase: true, nesting: 'other-is-child' },
38+
];
39+
40+
suite('CondaEnvManager - project isolation', () => {
41+
let sandbox: sinon.SinonSandbox;
42+
let cancellation: CancellationTokenSource;
43+
44+
function assertEnvironment(
45+
actual: PythonEnvironment | undefined,
46+
expected: PythonEnvironment | undefined,
47+
message?: string,
48+
): void {
49+
assert.strictEqual(actual?.envId.id, expected?.envId.id, message);
50+
}
51+
52+
setup(() => {
53+
sandbox = sinon.createSandbox();
54+
cancellation = new CancellationTokenSource();
55+
const condaPath = path.resolve('conda-isolation', 'conda');
56+
sandbox.stub(condaUtils, 'getConda').resolves(condaPath);
57+
sandbox.stub(condaUtils, 'getCondaPathSetting').returns(undefined);
58+
sandbox.stub(condaUtils, 'getCondaForGlobal').resolves(undefined);
59+
sandbox
60+
.stub(sourcing, 'constructCondaSourcingStatus')
61+
.resolves(new sourcing.CondaSourcingStatus(condaPath, path.dirname(condaPath)));
62+
sandbox.stub(telemetry, 'sendTelemetryEvent');
63+
sandbox
64+
.stub(windowApis, 'withProgress')
65+
.callsFake(async (_options, task) => task({ report: () => {} }, cancellation.token));
66+
});
67+
68+
teardown(() => {
69+
sandbox.restore();
70+
cancellation.dispose();
71+
});
72+
73+
for (const operation of ['initialize', 'refresh'] as const) {
74+
for (const scenario of scenarios) {
75+
const projectOrders = scenario.reverseProjectOrder ? [false, true] : [false];
76+
for (const ownerFirst of projectOrders) {
77+
test(`${operation}: project selections remain isolated (${scenario.name}, ownerFirst=${ownerFirst})`, async () => {
78+
const owner: PythonProject = {
79+
name: 'owner',
80+
uri: Uri.file(
81+
scenario.nesting === 'owner-is-child'
82+
? path.resolve('conda-isolation', 'project-a', 'nested')
83+
: path.resolve('conda-isolation', 'project-a'),
84+
),
85+
};
86+
const other: PythonProject = {
87+
name: 'other',
88+
uri: Uri.file(
89+
scenario.nesting === 'owner-is-child'
90+
? path.resolve('conda-isolation', 'project-a')
91+
: scenario.nesting === 'other-is-child'
92+
? path.resolve('conda-isolation', 'project-a', 'nested')
93+
: path.resolve('conda-isolation', 'project-b'),
94+
),
95+
};
96+
const projects = ownerFirst ? [owner, other] : [other, owner];
97+
const projectsBySpecificity = [...projects].sort(
98+
(a, b) => b.uri.fsPath.length - a.uri.fsPath.length,
99+
);
100+
const local = makeMockCondaEnvironment('project-env', Uri.joinPath(owner.uri, '.conda').fsPath);
101+
const base = makeMockCondaEnvironment('base', path.resolve('conda-isolation', 'base'));
102+
const otherLocal = makeMockCondaEnvironment(
103+
'other-project-env',
104+
Uri.joinPath(other.uri, '.conda').fsPath,
105+
);
106+
const external = makeMockCondaEnvironment('external', path.resolve('conda-isolation', 'external'));
107+
const api = typemoq.Mock.ofType<PythonEnvironmentApi>();
108+
api.setup((a) => a.getPythonProjects()).returns(() => projects);
109+
api.setup((a) => a.getPythonProject(typemoq.It.isAny())).returns((uri: Uri) =>
110+
projectsBySpecificity.find((project) => isSameOrParentPath(project.uri.fsPath, uri.fsPath)),
111+
);
112+
const refresh = sandbox
113+
.stub(condaUtils, 'refreshCondaEnvs')
114+
.resolves(scenario.hasBase ? [base] : []);
115+
const savedSelection = sandbox.stub(condaUtils, 'getCondaForWorkspace').resolves(undefined);
116+
const resolve = sandbox.stub(condaUtils, 'resolveCondaPath').resolves(external);
117+
const manager = new CondaEnvManager(
118+
typemoq.Mock.ofType<NativePythonFinder>().object,
119+
api.object,
120+
typemoq.Mock.ofType<LogOutputChannel>().object,
121+
);
122+
try {
123+
if (operation === 'refresh') {
124+
await manager.initialize();
125+
assertEnvironment(await manager.get(other.uri), scenario.hasBase ? base : undefined);
126+
}
127+
refresh.resolves([
128+
...(scenario.hasBase ? [base] : []),
129+
local,
130+
...(scenario.otherLocal ? [otherLocal] : []),
131+
]);
132+
const explicit =
133+
scenario.explicit === 'shared'
134+
? local
135+
: scenario.explicit === 'external'
136+
? external
137+
: undefined;
138+
savedSelection.withArgs(other.uri.fsPath).resolves(explicit?.environmentPath.fsPath);
139+
const expectedOther =
140+
explicit ?? (scenario.otherLocal ? otherLocal : scenario.hasBase ? base : undefined);
141+
if (operation === 'initialize') {
142+
await manager.initialize();
143+
} else {
144+
await manager.refresh(undefined);
145+
}
146+
147+
assertEnvironment(await manager.get(owner.uri), local);
148+
assertEnvironment(
149+
await manager.get(other.uri),
150+
expectedOther,
151+
"Only an explicit selection may make a project use another project's local environment",
152+
);
153+
assertEnvironment(
154+
await manager.get(Uri.joinPath(other.uri, 'main.py')),
155+
expectedOther,
156+
"Files in the other project must use that project's selected environment or fallback",
157+
);
158+
if (scenario.explicit === 'external') {
159+
sinon.assert.calledOnce(resolve);
160+
assert.strictEqual(resolve.firstCall.args[0], external.environmentPath.fsPath);
161+
} else {
162+
sinon.assert.notCalled(resolve);
163+
}
164+
} finally {
165+
manager.dispose();
166+
}
167+
});
168+
}
169+
}
170+
}
171+
172+
test('uses the real project manager to isolate nested project environments', async () => {
173+
sandbox.stub(workspaceApis, 'getConfiguration').returns({
174+
get: <T>(_key: string, defaultValue?: T) => defaultValue,
175+
} as WorkspaceConfiguration);
176+
sandbox.stub(workspaceApis, 'getWorkspaceFolders').returns([]);
177+
178+
const projects = new PythonProjectManagerImpl();
179+
const parent = projects.create('parent', Uri.file(path.resolve('conda-isolation', 'project-a')));
180+
const child = projects.create('child', Uri.joinPath(parent.uri, 'nested'));
181+
await projects.add([parent, child], { persistSettings: false });
182+
183+
const base = makeMockCondaEnvironment('base', path.resolve('conda-isolation', 'base'));
184+
const childLocal = makeMockCondaEnvironment('child-env', Uri.joinPath(child.uri, '.conda').fsPath);
185+
sandbox.stub(condaUtils, 'refreshCondaEnvs').resolves([base, childLocal]);
186+
sandbox.stub(condaUtils, 'getCondaForWorkspace').resolves(undefined);
187+
const api = {
188+
getPythonProjects: () => projects.getProjects(),
189+
getPythonProject: (uri: Uri) => projects.get(uri),
190+
} as unknown as PythonEnvironmentApi;
191+
const manager = new CondaEnvManager(
192+
typemoq.Mock.ofType<NativePythonFinder>().object,
193+
api,
194+
typemoq.Mock.ofType<LogOutputChannel>().object,
195+
);
196+
197+
try {
198+
await manager.initialize();
199+
200+
assertEnvironment(await manager.get(parent.uri), base);
201+
assertEnvironment(await manager.get(Uri.joinPath(parent.uri, 'main.py')), base);
202+
assertEnvironment(await manager.get(child.uri), childLocal);
203+
assertEnvironment(await manager.get(Uri.joinPath(child.uri, 'main.py')), childLocal);
204+
} finally {
205+
manager.dispose();
206+
projects.dispose();
207+
}
208+
});
209+
});

0 commit comments

Comments
 (0)