Skip to content

Commit 7b2fb86

Browse files
authored
bug fix: enhance conda environment management with sourcing status and updated shell activation support (#693)
1 parent 4a1a772 commit 7b2fb86

File tree

4 files changed

+603
-188
lines changed

4 files changed

+603
-188
lines changed

src/managers/conda/condaEnvManager.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { traceError } from '../../common/logging';
2424
import { createDeferred, Deferred } from '../../common/utils/deferred';
2525
import { showErrorMessage, withProgress } from '../../common/window.apis';
2626
import { NativePythonFinder } from '../common/nativePythonFinder';
27+
import { CondaSourcingStatus } from './condaSourcingUtils';
2728
import {
2829
checkForNoPythonCondaEnvironment,
2930
clearCondaCache,
@@ -52,6 +53,8 @@ export class CondaEnvManager implements EnvironmentManager, Disposable {
5253
private readonly _onDidChangeEnvironments = new EventEmitter<DidChangeEnvironmentsEventArgs>();
5354
public readonly onDidChangeEnvironments = this._onDidChangeEnvironments.event;
5455

56+
public sourcingInformation: CondaSourcingStatus | undefined;
57+
5558
constructor(
5659
private readonly nativeFinder: NativePythonFinder,
5760
private readonly api: PythonEnvironmentApi,
Lines changed: 355 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,355 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
import * as fse from 'fs-extra';
5+
import * as path from 'path';
6+
import { traceError, traceInfo, traceVerbose } from '../../common/logging';
7+
import { isWindows } from '../../common/utils/platformUtils';
8+
9+
/**
10+
* Represents the status of conda sourcing in the current environment
11+
*/
12+
export class CondaSourcingStatus {
13+
/**
14+
* Creates a new CondaSourcingStatus instance
15+
* @param condaPath Path to the conda installation
16+
* @param condaFolder Path to the conda installation folder (derived from condaPath)
17+
* @param isActiveOnLaunch Whether conda was activated before VS Code launch
18+
* @param globalSourcingScript Path to the global sourcing script (if exists)
19+
* @param shellSourcingScripts List of paths to shell-specific sourcing scripts
20+
*/
21+
constructor(
22+
public readonly condaPath: string,
23+
public readonly condaFolder: string,
24+
public isActiveOnLaunch?: boolean,
25+
public globalSourcingScript?: string,
26+
public shellSourcingScripts?: string[],
27+
) {}
28+
29+
/**
30+
* Returns a formatted string representation of the conda sourcing status
31+
*/
32+
toString(): string {
33+
const lines: string[] = [];
34+
lines.push('Conda Sourcing Status:');
35+
lines.push(`├─ Conda Path: ${this.condaPath}`);
36+
lines.push(`├─ Conda Folder: ${this.condaFolder}`);
37+
lines.push(`├─ Active on Launch: ${this.isActiveOnLaunch ?? 'false'}`);
38+
39+
if (this.globalSourcingScript) {
40+
lines.push(`├─ Global Sourcing Script: ${this.globalSourcingScript}`);
41+
}
42+
43+
if (this.shellSourcingScripts?.length) {
44+
lines.push('└─ Shell-specific Sourcing Scripts:');
45+
this.shellSourcingScripts.forEach((script, index, array) => {
46+
const isLast = index === array.length - 1;
47+
if (script) {
48+
// Only include scripts that exist
49+
lines.push(` ${isLast ? '└─' : '├─'} ${script}`);
50+
}
51+
});
52+
} else {
53+
lines.push('└─ No Shell-specific Sourcing Scripts Found');
54+
}
55+
56+
return lines.join('\n');
57+
}
58+
}
59+
60+
/**
61+
* Constructs the conda sourcing status for a given conda installation
62+
* @param condaPath The path to the conda executable
63+
* @returns A CondaSourcingStatus object containing:
64+
* - Whether conda was active when VS Code launched
65+
* - Path to global sourcing script (if found)
66+
* - Paths to shell-specific sourcing scripts (if found)
67+
*
68+
* This function checks:
69+
* 1. If conda is already active in the current shell (CONDA_SHLVL)
70+
* 2. Location of the global activation script
71+
* 3. Location of shell-specific activation scripts
72+
*/
73+
export async function constructCondaSourcingStatus(condaPath: string): Promise<CondaSourcingStatus> {
74+
const condaFolder = path.dirname(path.dirname(condaPath));
75+
let sourcingStatus = new CondaSourcingStatus(condaPath, condaFolder);
76+
77+
// The `conda_shlvl` value indicates whether conda is properly initialized in the current shell:
78+
// - `-1`: Conda has never been sourced
79+
// - `undefined`: No shell level information available
80+
// - `0 or higher`: Conda is properly sourced in the shell
81+
const condaShlvl = process.env.CONDA_SHLVL;
82+
if (condaShlvl && parseInt(condaShlvl) >= 0) {
83+
sourcingStatus.isActiveOnLaunch = true;
84+
// if activation already occurred, no need to find further scripts
85+
return sourcingStatus;
86+
}
87+
88+
// Attempt to find the GLOBAL conda sourcing script
89+
const globalSourcingScript: string | undefined = await findGlobalSourcingScript(sourcingStatus.condaFolder);
90+
if (globalSourcingScript) {
91+
sourcingStatus.globalSourcingScript = globalSourcingScript;
92+
// note: future iterations could decide to exit here instead of continuing to generate all the other activation scripts
93+
}
94+
95+
// find and save all of the shell specific sourcing scripts
96+
sourcingStatus.shellSourcingScripts = await findShellSourcingScripts(sourcingStatus);
97+
98+
return sourcingStatus;
99+
}
100+
101+
/**
102+
* Finds the global conda activation script for the given conda installation
103+
* @param condaPath The path to the conda executable
104+
* @returns The path to the global activation script if it exists, undefined otherwise
105+
*
106+
* On Windows, this will look for 'Scripts/activate.bat'
107+
* On Unix systems, this will look for 'bin/activate'
108+
*/
109+
export async function findGlobalSourcingScript(condaFolder: string): Promise<string | undefined> {
110+
const sourcingScript = isWindows()
111+
? path.join(condaFolder, 'Scripts', 'activate.bat')
112+
: path.join(condaFolder, 'bin', 'activate');
113+
114+
if (await fse.pathExists(sourcingScript)) {
115+
traceInfo(`Found global conda sourcing script at: ${sourcingScript}`);
116+
return sourcingScript;
117+
} else {
118+
traceInfo(`No global conda sourcing script found. at: ${sourcingScript}`);
119+
return undefined;
120+
}
121+
}
122+
123+
export async function findShellSourcingScripts(sourcingStatus: CondaSourcingStatus): Promise<string[]> {
124+
const logs: string[] = [];
125+
logs.push('=== Conda Sourcing Shell Script Search ===');
126+
127+
let ps1Script: string | undefined;
128+
let shScript: string | undefined;
129+
let cmdActivate: string | undefined;
130+
131+
try {
132+
// Search for PowerShell hook script (conda-hook.ps1)
133+
logs.push('Searching for PowerShell hook script...');
134+
try {
135+
ps1Script = await getCondaHookPs1Path(sourcingStatus.condaFolder);
136+
logs.push(` Path: ${ps1Script ?? '✗ Not found'}`);
137+
} catch (err) {
138+
logs.push(
139+
` Error during PowerShell script search: ${err instanceof Error ? err.message : 'Unknown error'}`,
140+
);
141+
}
142+
143+
// Search for Shell script (conda.sh)
144+
logs.push('\nSearching for Shell script...');
145+
try {
146+
shScript = await getCondaShPath(sourcingStatus.condaFolder);
147+
logs.push(` Path: ${shScript ?? '✗ Not found'}`);
148+
} catch (err) {
149+
logs.push(` Error during Shell script search: ${err instanceof Error ? err.message : 'Unknown error'}`);
150+
}
151+
152+
// Search for Windows CMD script (activate.bat)
153+
logs.push('\nSearching for Windows CMD script...');
154+
try {
155+
cmdActivate = await getCondaBatActivationFile(sourcingStatus.condaPath);
156+
logs.push(` Path: ${cmdActivate ?? '✗ Not found'}`);
157+
} catch (err) {
158+
logs.push(` Error during CMD script search: ${err instanceof Error ? err.message : 'Unknown error'}`);
159+
}
160+
} catch (error) {
161+
logs.push(`\nCritical error during script search: ${error instanceof Error ? error.message : 'Unknown error'}`);
162+
} finally {
163+
logs.push('\nSearch Summary:');
164+
logs.push(` PowerShell: ${ps1Script ? '✓' : '✗'}`);
165+
logs.push(` Shell: ${shScript ? '✓' : '✗'}`);
166+
logs.push(` CMD: ${cmdActivate ? '✓' : '✗'}`);
167+
logs.push('============================');
168+
169+
// Log everything at once
170+
traceVerbose(logs.join('\n'));
171+
}
172+
173+
return [ps1Script, shScript, cmdActivate] as string[];
174+
}
175+
176+
/**
177+
* Returns the best guess path to conda-hook.ps1 given a conda executable path.
178+
*
179+
* Searches for conda-hook.ps1 in these locations (relative to the conda root):
180+
* - shell/condabin/
181+
* - Library/shell/condabin/
182+
* - condabin/
183+
* - etc/profile.d/
184+
*/
185+
export async function getCondaHookPs1Path(condaFolder: string): Promise<string | undefined> {
186+
// Create the promise for finding the hook path
187+
const hookPathPromise = (async () => {
188+
const condaRootCandidates: string[] = [
189+
path.join(condaFolder, 'shell', 'condabin'),
190+
path.join(condaFolder, 'Library', 'shell', 'condabin'),
191+
path.join(condaFolder, 'condabin'),
192+
path.join(condaFolder, 'etc', 'profile.d'),
193+
];
194+
195+
const checks = condaRootCandidates.map(async (hookSearchDir) => {
196+
const candidate = path.join(hookSearchDir, 'conda-hook.ps1');
197+
if (await fse.pathExists(candidate)) {
198+
traceInfo(`Conda hook found at: ${candidate}`);
199+
return candidate;
200+
}
201+
return undefined;
202+
});
203+
const results = await Promise.all(checks);
204+
const found = results.find(Boolean);
205+
if (found) {
206+
return found as string;
207+
}
208+
return undefined;
209+
})();
210+
211+
return hookPathPromise;
212+
}
213+
214+
/**
215+
* Helper function that checks for a file in a list of locations.
216+
* Returns the first location where the file exists, or undefined if not found.
217+
*/
218+
async function findFileInLocations(locations: string[], description: string): Promise<string | undefined> {
219+
for (const location of locations) {
220+
if (await fse.pathExists(location)) {
221+
traceInfo(`${description} found in ${location}`);
222+
return location;
223+
}
224+
}
225+
return undefined;
226+
}
227+
228+
/**
229+
* Returns the path to conda.sh given a conda executable path.
230+
*
231+
* Searches for conda.sh in these locations (relative to the conda root):
232+
* - etc/profile.d/conda.sh
233+
* - shell/etc/profile.d/conda.sh
234+
* - Library/etc/profile.d/conda.sh
235+
* - lib/pythonX.Y/site-packages/conda/shell/etc/profile.d/conda.sh
236+
* - site-packages/conda/shell/etc/profile.d/conda.sh
237+
* Also checks some system-level locations
238+
*/
239+
async function getCondaShPath(condaFolder: string): Promise<string | undefined> {
240+
// Create the promise for finding the conda.sh path
241+
const shPathPromise = (async () => {
242+
// First try standard conda installation locations
243+
const standardLocations = [
244+
path.join(condaFolder, 'etc', 'profile.d', 'conda.sh'),
245+
path.join(condaFolder, 'shell', 'etc', 'profile.d', 'conda.sh'),
246+
path.join(condaFolder, 'Library', 'etc', 'profile.d', 'conda.sh'),
247+
];
248+
249+
// Check standard locations first
250+
const standardLocation = await findFileInLocations(standardLocations, 'conda.sh');
251+
if (standardLocation) {
252+
return standardLocation;
253+
}
254+
255+
// If not found in standard locations, try pip install locations
256+
// First, find all python* directories in lib
257+
let pythonDirs: string[] = [];
258+
const libPath = path.join(condaFolder, 'lib');
259+
try {
260+
const dirs = await fse.readdir(libPath);
261+
pythonDirs = dirs.filter((dir) => dir.startsWith('python'));
262+
} catch (err) {
263+
traceVerbose(`No lib directory found at ${libPath}, ${err}`);
264+
}
265+
266+
const pipInstallLocations = [
267+
...pythonDirs.map((ver) =>
268+
path.join(condaFolder, 'lib', ver, 'site-packages', 'conda', 'shell', 'etc', 'profile.d', 'conda.sh'),
269+
),
270+
path.join(condaFolder, 'site-packages', 'conda', 'shell', 'etc', 'profile.d', 'conda.sh'),
271+
];
272+
273+
// Check pip install locations
274+
const pipLocation = await findFileInLocations(pipInstallLocations, 'conda.sh');
275+
if (pipLocation) {
276+
traceError(
277+
'WARNING: conda.sh was found in a pip install location. ' +
278+
'This is not a supported configuration and may be deprecated in the future. ' +
279+
'Please install conda in a standard location. ' +
280+
'See https://docs.conda.io/projects/conda/en/latest/user-guide/install/index.html for proper installation instructions.',
281+
);
282+
return pipLocation;
283+
}
284+
return undefined;
285+
})();
286+
287+
return shPathPromise;
288+
}
289+
290+
/**
291+
* Returns the path to the Windows batch activation file (activate.bat) for conda
292+
* @param condaPath The path to the conda executable
293+
* @returns The path to activate.bat if it exists in the same directory as conda.exe, undefined otherwise
294+
*
295+
* This file is used specifically for CMD.exe activation on Windows systems.
296+
* It should be located in the same directory as the conda executable.
297+
*/
298+
async function getCondaBatActivationFile(condaPath: string): Promise<string | undefined> {
299+
const cmdActivate = path.join(path.dirname(condaPath), 'activate.bat');
300+
if (await fse.pathExists(cmdActivate)) {
301+
return cmdActivate;
302+
}
303+
return undefined;
304+
}
305+
306+
/**
307+
* Returns the path to the local conda activation script
308+
* @param condaPath The path to the conda executable
309+
* @returns Promise that resolves to:
310+
* - The path to the local 'activate' script if it exists in the same directory as conda
311+
* - undefined if the script is not found
312+
*
313+
* This function checks for a local 'activate' script in the same directory as the conda executable.
314+
* This script is used for direct conda activation without shell-specific configuration.
315+
*/
316+
317+
const knownSourcingScriptCache: string[] = [];
318+
export async function getLocalActivationScript(condaPath: string): Promise<string | undefined> {
319+
// Define all possible paths to check
320+
const paths = [
321+
// Direct path
322+
isWindows() ? path.join(condaPath, 'Scripts', 'activate') : path.join(condaPath, 'bin', 'activate'),
323+
// One level up
324+
isWindows()
325+
? path.join(path.dirname(condaPath), 'Scripts', 'activate')
326+
: path.join(path.dirname(condaPath), 'bin', 'activate'),
327+
// Two levels up
328+
isWindows()
329+
? path.join(path.dirname(path.dirname(condaPath)), 'Scripts', 'activate')
330+
: path.join(path.dirname(path.dirname(condaPath)), 'bin', 'activate'),
331+
];
332+
333+
// Check each path in sequence
334+
for (const sourcingScript of paths) {
335+
// Check if any of the paths are in the cache
336+
if (knownSourcingScriptCache.includes(sourcingScript)) {
337+
traceVerbose(`Found local activation script in cache at: ${sourcingScript}`);
338+
return sourcingScript;
339+
}
340+
try {
341+
const exists = await fse.pathExists(sourcingScript);
342+
if (exists) {
343+
traceInfo(`Found local activation script at: ${sourcingScript}, adding to cache.`);
344+
knownSourcingScriptCache.push(sourcingScript);
345+
return sourcingScript;
346+
}
347+
} catch (err) {
348+
traceError(`Error checking for local activation script at ${sourcingScript}: ${err}`);
349+
continue;
350+
}
351+
}
352+
353+
traceVerbose('No local activation script found in any of the expected locations');
354+
return undefined;
355+
}

0 commit comments

Comments
 (0)