Skip to content

Commit 35a0aa1

Browse files
committed
fix the bug
1 parent c6e4fe7 commit 35a0aa1

File tree

2 files changed

+268
-2
lines changed

2 files changed

+268
-2
lines changed

src/managers/conda/condaEnvManager.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -486,10 +486,17 @@ export class CondaEnvManager implements EnvironmentManager, Disposable {
486486

487487
private findEnvironmentByPath(fsPath: string): PythonEnvironment | undefined {
488488
const normalized = normalizePath(fsPath);
489+
490+
// Prefer exact match first to avoid ambiguous parent/grandparent collisions.
491+
// E.g. base env at /miniconda3 must not be confused with a named env at
492+
// /miniconda3/envs/<name> whose grandparent is also /miniconda3.
493+
const exact = this.collection.find((e) => normalizePath(e.environmentPath.fsPath) === normalized);
494+
if (exact) {
495+
return exact;
496+
}
497+
489498
return this.collection.find((e) => {
490-
const n = normalizePath(e.environmentPath.fsPath);
491499
return (
492-
n === normalized ||
493500
normalizePath(path.dirname(e.environmentPath.fsPath)) === normalized ||
494501
normalizePath(path.dirname(path.dirname(e.environmentPath.fsPath))) === normalized
495502
);
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import assert from 'assert';
3+
import * as sinon from 'sinon';
4+
import { Uri } from 'vscode';
5+
import { PythonEnvironment, PythonEnvironmentApi } from '../../../api';
6+
import { PythonEnvironmentImpl } from '../../../internal.api';
7+
import { CondaEnvManager } from '../../../managers/conda/condaEnvManager';
8+
import { NativePythonFinder } from '../.././../managers/common/nativePythonFinder';
9+
10+
/**
11+
* Helper to create a minimal PythonEnvironment stub with required fields.
12+
* Only `name`, `environmentPath`, and `version` matter for findEnvironmentByPath.
13+
*/
14+
function makeEnv(name: string, envPath: string, version: string = '3.12.0'): PythonEnvironment {
15+
return new PythonEnvironmentImpl(
16+
{ id: `${name}-test`, managerId: 'ms-python.python:conda' },
17+
{
18+
name,
19+
displayName: `${name} (${version})`,
20+
displayPath: envPath,
21+
version,
22+
environmentPath: Uri.file(envPath),
23+
sysPrefix: envPath,
24+
execInfo: {
25+
run: { executable: 'python' },
26+
},
27+
},
28+
);
29+
}
30+
31+
/**
32+
* Creates a CondaEnvManager with a given collection, bypassing initialization.
33+
*/
34+
function createManagerWithCollection(collection: PythonEnvironment[]): CondaEnvManager {
35+
const manager = new CondaEnvManager(
36+
{} as NativePythonFinder,
37+
{} as PythonEnvironmentApi,
38+
{ info: sinon.stub(), error: sinon.stub(), warn: sinon.stub() } as any,
39+
);
40+
(manager as any).collection = collection;
41+
return manager;
42+
}
43+
44+
/**
45+
* Calls the private findEnvironmentByPath method on the manager.
46+
*/
47+
function findByPath(manager: CondaEnvManager, fsPath: string): PythonEnvironment | undefined {
48+
return (manager as any).findEnvironmentByPath(fsPath);
49+
}
50+
51+
suite('CondaEnvManager - findEnvironmentByPath', () => {
52+
teardown(() => {
53+
sinon.restore();
54+
});
55+
56+
// --- Core bug fix: base vs named env collision ---
57+
58+
test('Exact match on base prefix returns base, not a named env with higher version', () => {
59+
// This is the core bug scenario from issue #25814.
60+
// Named envs under /miniconda3/envs/<name> have grandparent /miniconda3,
61+
// same as base's own path. With version-sorted collection, a named env
62+
// with a higher Python version would appear first and incorrectly match.
63+
const base = makeEnv('base', '/home/user/miniconda3', '3.12.0');
64+
const namedHigher = makeEnv('torch', '/home/user/miniconda3/envs/torch', '3.13.0');
65+
66+
// Collection sorted by version descending (torch first, higher version)
67+
const manager = createManagerWithCollection([namedHigher, base]);
68+
69+
const result = findByPath(manager, '/home/user/miniconda3');
70+
assert.strictEqual(result, base, 'Should return base env via exact match, not torch via grandparent');
71+
});
72+
73+
test('Exact match on base prefix works with many named envs of varying versions', () => {
74+
const base = makeEnv('base', '/home/user/miniconda3', '3.11.0');
75+
const envA = makeEnv('alpha', '/home/user/miniconda3/envs/alpha', '3.13.0');
76+
const envB = makeEnv('beta', '/home/user/miniconda3/envs/beta', '3.12.0');
77+
const envC = makeEnv('gamma', '/home/user/miniconda3/envs/gamma', '3.10.0');
78+
79+
// Sorted by version descending: alpha(3.13), beta(3.12), base(3.11), gamma(3.10)
80+
const manager = createManagerWithCollection([envA, envB, base, envC]);
81+
82+
const result = findByPath(manager, '/home/user/miniconda3');
83+
assert.strictEqual(result, base, 'Should return base even when multiple named envs have higher versions');
84+
});
85+
86+
// --- Standard exact match cases ---
87+
88+
test('Exact match returns the correct named environment', () => {
89+
const base = makeEnv('base', '/home/user/miniconda3', '3.12.0');
90+
const myenv = makeEnv('myenv', '/home/user/miniconda3/envs/myenv', '3.11.0');
91+
92+
const manager = createManagerWithCollection([base, myenv]);
93+
94+
const result = findByPath(manager, '/home/user/miniconda3/envs/myenv');
95+
assert.strictEqual(result, myenv);
96+
});
97+
98+
test('Exact match returns correct env when path is a prefix env outside envs dir', () => {
99+
const prefixEnv = makeEnv('project', '/home/user/projects/myproject/.conda', '3.12.0');
100+
const manager = createManagerWithCollection([prefixEnv]);
101+
102+
const result = findByPath(manager, '/home/user/projects/myproject/.conda');
103+
assert.strictEqual(result, prefixEnv);
104+
});
105+
106+
// --- Parent directory match (one level up) ---
107+
108+
test('Parent dir match resolves executable path to env (bin/ inside env)', () => {
109+
// When given a path like /miniconda3/envs/myenv/bin, dirname of the env
110+
// is /miniconda3/envs/myenv and the path is /miniconda3/envs/myenv/bin,
111+
// so parent match: dirname(envPath) matches the lookup path won't work here.
112+
// Actually parent match means: dirname(environmentPath) === lookupPath.
113+
// For bin match, we'd pass /miniconda3/envs/myenv/bin and
114+
// dirname(/miniconda3/envs/myenv) = /miniconda3/envs ≠ /miniconda3/envs/myenv/bin
115+
// So this case uses grandparent. Let me test a real parent scenario:
116+
// If we have env at /miniconda3/envs/myenv/python (subdir) and look up /miniconda3/envs/myenv
117+
const env = makeEnv('myenv', '/home/user/miniconda3/envs/myenv/python', '3.12.0');
118+
const manager = createManagerWithCollection([env]);
119+
120+
// dirname(/miniconda3/envs/myenv/python) = /miniconda3/envs/myenv
121+
const result = findByPath(manager, '/home/user/miniconda3/envs/myenv');
122+
assert.strictEqual(result, env, 'Should match via parent directory');
123+
});
124+
125+
// --- Grandparent directory match (two levels up) ---
126+
127+
test('Grandparent dir match resolves executable path to env (bin/python inside env)', () => {
128+
// environmentPath = /miniconda3/envs/myenv/bin/python
129+
// dirname(dirname(path)) = /miniconda3/envs/myenv
130+
//
131+
// This is the typical case where environmentPath points to the Python binary
132+
// and we look up the environment prefix.
133+
const env = makeEnv('myenv', '/home/user/miniconda3/envs/myenv/bin/python', '3.12.0');
134+
const manager = createManagerWithCollection([env]);
135+
136+
const result = findByPath(manager, '/home/user/miniconda3/envs/myenv');
137+
assert.strictEqual(result, env, 'Should match via grandparent directory');
138+
});
139+
140+
// --- No match ---
141+
142+
test('Returns undefined when no environment matches', () => {
143+
const base = makeEnv('base', '/home/user/miniconda3', '3.12.0');
144+
const manager = createManagerWithCollection([base]);
145+
146+
const result = findByPath(manager, '/opt/other/path');
147+
assert.strictEqual(result, undefined);
148+
});
149+
150+
test('Returns undefined for empty collection', () => {
151+
const manager = createManagerWithCollection([]);
152+
const result = findByPath(manager, '/home/user/miniconda3');
153+
assert.strictEqual(result, undefined);
154+
});
155+
156+
// --- Priority: exact over parent, parent over grandparent ---
157+
158+
test('Exact match takes priority over parent match of a different env', () => {
159+
// envA is at /a/b/c and envB is at /a/b/c/sub
160+
// Looking up /a/b/c should return envA (exact), not envB (parent)
161+
const envA = makeEnv('envA', '/a/b/c', '3.12.0');
162+
const envB = makeEnv('envB', '/a/b/c/sub', '3.12.0');
163+
const manager = createManagerWithCollection([envB, envA]); // envB first in iteration
164+
165+
const result = findByPath(manager, '/a/b/c');
166+
assert.strictEqual(result, envA, 'Exact match should win over parent match');
167+
});
168+
169+
test('Exact match takes priority over grandparent match of a different env', () => {
170+
const envA = makeEnv('envA', '/a/b', '3.12.0');
171+
const envB = makeEnv('envB', '/a/b/c/d', '3.13.0');
172+
const manager = createManagerWithCollection([envB, envA]); // envB first (higher version)
173+
174+
// dirname(dirname(/a/b/c/d)) = /a/b which also matches envA exactly
175+
const result = findByPath(manager, '/a/b');
176+
assert.strictEqual(result, envA, 'Exact match should win over grandparent match');
177+
});
178+
179+
// --- Windows-style paths ---
180+
181+
test('Works with Windows-style backslash paths', () => {
182+
const base = makeEnv('base', 'C:\\Users\\user\\miniconda3', '3.12.0');
183+
const named = makeEnv('torch', 'C:\\Users\\user\\miniconda3\\envs\\torch', '3.13.0');
184+
185+
const manager = createManagerWithCollection([named, base]);
186+
187+
const result = findByPath(manager, 'C:\\Users\\user\\miniconda3');
188+
assert.strictEqual(result, base, 'Should return base on Windows paths');
189+
});
190+
191+
test('Windows: exact match on named env path', () => {
192+
const base = makeEnv('base', 'C:\\Users\\user\\miniconda3', '3.12.0');
193+
const named = makeEnv('myenv', 'C:\\Users\\user\\miniconda3\\envs\\myenv', '3.11.0');
194+
195+
const manager = createManagerWithCollection([base, named]);
196+
197+
const result = findByPath(manager, 'C:\\Users\\user\\miniconda3\\envs\\myenv');
198+
assert.strictEqual(result, named);
199+
});
200+
201+
// --- Edge: base is the only env ---
202+
203+
test('Base as only env is found via exact match', () => {
204+
const base = makeEnv('base', '/home/user/miniconda3', '3.12.0');
205+
const manager = createManagerWithCollection([base]);
206+
207+
const result = findByPath(manager, '/home/user/miniconda3');
208+
assert.strictEqual(result, base);
209+
});
210+
211+
// --- Edge: multiple envs with same version (alphabetical sort) ---
212+
213+
test('Works when base and named env have the same Python version', () => {
214+
const base = makeEnv('base', '/home/user/miniconda3', '3.12.0');
215+
const named = makeEnv('aaa', '/home/user/miniconda3/envs/aaa', '3.12.0');
216+
217+
// Same version, 'aaa' sorts before 'base' alphabetically
218+
const manager = createManagerWithCollection([named, base]);
219+
220+
const result = findByPath(manager, '/home/user/miniconda3');
221+
assert.strictEqual(result, base, 'Should return base even when named env sorts first alphabetically');
222+
});
223+
224+
// --- Edge: prefix env inside workspace (not under envs/) ---
225+
226+
test('Prefix env inside workspace does not collide with base', () => {
227+
const base = makeEnv('base', '/home/user/miniconda3', '3.13.0');
228+
const prefixEnv = makeEnv('.conda', '/home/user/project/.conda', '3.12.0');
229+
230+
const manager = createManagerWithCollection([base, prefixEnv]);
231+
232+
const result = findByPath(manager, '/home/user/project/.conda');
233+
assert.strictEqual(result, prefixEnv);
234+
});
235+
236+
// --- Edge: deeply nested path that doesn't match anything ---
237+
238+
test('Path that only matches at 3+ levels up does not match', () => {
239+
// environmentPath = /a/b/c/d/e, looking up /a/b
240+
// dirname = /a/b/c/d, grandparent = /a/b/c — neither matches /a/b
241+
const env = makeEnv('deep', '/a/b/c/d/e', '3.12.0');
242+
const manager = createManagerWithCollection([env]);
243+
244+
const result = findByPath(manager, '/a/b');
245+
assert.strictEqual(result, undefined, 'Should not match beyond grandparent');
246+
});
247+
248+
// --- Edge: trailing separator normalization ---
249+
250+
test('Fallback still works when no exact match exists', () => {
251+
// An env whose environmentPath is a binary path, not a prefix
252+
const env = makeEnv('myenv', '/home/user/miniconda3/envs/myenv/bin/python3', '3.12.0');
253+
const manager = createManagerWithCollection([env]);
254+
255+
// Looking up the prefix — should find it via grandparent
256+
const result = findByPath(manager, '/home/user/miniconda3/envs/myenv');
257+
assert.strictEqual(result, env);
258+
});
259+
});

0 commit comments

Comments
 (0)