Skip to content

Commit 2268d53

Browse files
authored
Add support to delete and re-create .conda environments (#21977)
Fix #21828
1 parent df0b493 commit 2268d53

File tree

8 files changed

+354
-29
lines changed

8 files changed

+354
-29
lines changed

src/client/common/utils/localize.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -464,8 +464,10 @@ export namespace CreateEnv {
464464
export const error = l10n.t('Creating virtual environment failed with error.');
465465
export const tomlExtrasQuickPickTitle = l10n.t('Select optional dependencies to install from pyproject.toml');
466466
export const requirementsQuickPickTitle = l10n.t('Select dependencies to install');
467-
export const recreate = l10n.t('Recreate');
468-
export const recreateDescription = l10n.t('Delete existing ".venv" environment and create a new one');
467+
export const recreate = l10n.t('Delete and Recreate');
468+
export const recreateDescription = l10n.t(
469+
'Delete existing ".venv" directory and create a new ".venv" environment',
470+
);
469471
export const useExisting = l10n.t('Use Existing');
470472
export const useExistingDescription = l10n.t('Use existing ".venv" environment with no changes to it');
471473
export const existingVenvQuickPickPlaceholder = l10n.t(
@@ -485,6 +487,16 @@ export namespace CreateEnv {
485487
);
486488
export const creating = l10n.t('Creating conda environment...');
487489
export const providerDescription = l10n.t('Creates a `.conda` Conda environment in the current workspace');
490+
491+
export const recreate = l10n.t('Delete and Recreate');
492+
export const recreateDescription = l10n.t('Delete existing ".conda" environment and create a new one');
493+
export const useExisting = l10n.t('Use Existing');
494+
export const useExistingDescription = l10n.t('Use existing ".conda" environment with no changes to it');
495+
export const existingCondaQuickPickPlaceholder = l10n.t(
496+
'Choose an option to handle the existing ".conda" environment',
497+
);
498+
export const deletingEnvironmentProgress = l10n.t('Deleting existing ".conda" environment...');
499+
export const errorDeletingEnvironment = l10n.t('Error while deleting existing ".conda" environment.');
488500
}
489501
}
490502

src/client/pythonEnvironments/creation/common/commonUtils.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,11 @@ export function getVenvExecutable(workspaceFolder: WorkspaceFolder): string {
3232
}
3333
return path.join(getVenvPath(workspaceFolder), 'bin', 'python');
3434
}
35+
36+
export function getPrefixCondaEnvPath(workspaceFolder: WorkspaceFolder): string {
37+
return path.join(workspaceFolder.uri.fsPath, '.conda');
38+
}
39+
40+
export async function hasPrefixCondaEnv(workspaceFolder: WorkspaceFolder): Promise<boolean> {
41+
return fs.pathExists(getPrefixCondaEnvPath(workspaceFolder));
42+
}

src/client/pythonEnvironments/creation/provider/condaCreationProvider.ts

Lines changed: 61 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,18 @@ import { CreateEnvironmentProgress } from '../types';
99
import { pickWorkspaceFolder } from '../common/workspaceSelection';
1010
import { execObservable } from '../../../common/process/rawProcessApis';
1111
import { createDeferred } from '../../../common/utils/async';
12-
import { getEnvironmentVariable, getOSType, OSType } from '../../../common/utils/platform';
12+
import { getOSType, OSType } from '../../../common/utils/platform';
1313
import { createCondaScript } from '../../../common/process/internal/scripts';
1414
import { Common, CreateEnv } from '../../../common/utils/localize';
15-
import { getCondaBaseEnv, pickPythonVersion } from './condaUtils';
16-
import { showErrorMessageWithLogs } from '../common/commonUtils';
15+
import {
16+
ExistingCondaAction,
17+
deleteEnvironment,
18+
getCondaBaseEnv,
19+
getPathEnvVariableForConda,
20+
pickExistingCondaAction,
21+
pickPythonVersion,
22+
} from './condaUtils';
23+
import { getPrefixCondaEnvPath, showErrorMessageWithLogs } from '../common/commonUtils';
1724
import { MultiStepAction, MultiStepNode, withProgress } from '../../../common/vscodeApis/windowApis';
1825
import { EventName } from '../../../telemetry/constants';
1926
import { sendTelemetryEvent } from '../../../telemetry';
@@ -83,22 +90,7 @@ async function createCondaEnv(
8390
});
8491

8592
const deferred = createDeferred<string>();
86-
let pathEnv = getEnvironmentVariable('PATH') || getEnvironmentVariable('Path') || '';
87-
if (getOSType() === OSType.Windows) {
88-
// On windows `conda.bat` is used, which adds the following bin directories to PATH
89-
// then launches `conda.exe` which is a stub to `python.exe -m conda`. Here, we are
90-
// instead using the `python.exe` that ships with conda to run a python script that
91-
// handles conda env creation and package installation.
92-
// See conda issue: https://github.com/conda/conda/issues/11399
93-
const root = path.dirname(command);
94-
const libPath1 = path.join(root, 'Library', 'bin');
95-
const libPath2 = path.join(root, 'Library', 'mingw-w64', 'bin');
96-
const libPath3 = path.join(root, 'Library', 'usr', 'bin');
97-
const libPath4 = path.join(root, 'bin');
98-
const libPath5 = path.join(root, 'Scripts');
99-
const libPath = [libPath1, libPath2, libPath3, libPath4, libPath5].join(path.delimiter);
100-
pathEnv = `${libPath}${path.delimiter}${pathEnv}`;
101-
}
93+
const pathEnv = getPathEnvVariableForConda(command);
10294
traceLog('Running Conda Env creation script: ', [command, ...args]);
10395
const { proc, out, dispose } = execObservable(command, args, {
10496
mergeStdOutErr: true,
@@ -182,6 +174,29 @@ async function createEnvironment(options?: CreateEnvironmentOptions): Promise<Cr
182174
undefined,
183175
);
184176

177+
let existingCondaAction: ExistingCondaAction | undefined;
178+
const existingEnvStep = new MultiStepNode(
179+
workspaceStep,
180+
async (context?: MultiStepAction) => {
181+
if (workspace && context === MultiStepAction.Continue) {
182+
try {
183+
existingCondaAction = await pickExistingCondaAction(workspace);
184+
return MultiStepAction.Continue;
185+
} catch (ex) {
186+
if (ex === MultiStepAction.Back || ex === MultiStepAction.Cancel) {
187+
return ex;
188+
}
189+
throw ex;
190+
}
191+
} else if (context === MultiStepAction.Back) {
192+
return MultiStepAction.Back;
193+
}
194+
return MultiStepAction.Continue;
195+
},
196+
undefined,
197+
);
198+
workspaceStep.next = existingEnvStep;
199+
185200
let version: string | undefined;
186201
const versionStep = new MultiStepNode(
187202
workspaceStep,
@@ -204,13 +219,39 @@ async function createEnvironment(options?: CreateEnvironmentOptions): Promise<Cr
204219
},
205220
undefined,
206221
);
207-
workspaceStep.next = versionStep;
222+
existingEnvStep.next = versionStep;
208223

209224
const action = await MultiStepNode.run(workspaceStep);
210225
if (action === MultiStepAction.Back || action === MultiStepAction.Cancel) {
211226
throw action;
212227
}
213228

229+
if (workspace) {
230+
if (existingCondaAction === ExistingCondaAction.Recreate) {
231+
sendTelemetryEvent(EventName.ENVIRONMENT_DELETE, undefined, {
232+
environmentType: 'conda',
233+
status: 'triggered',
234+
});
235+
if (await deleteEnvironment(workspace, getExecutableCommand(conda))) {
236+
sendTelemetryEvent(EventName.ENVIRONMENT_DELETE, undefined, {
237+
environmentType: 'conda',
238+
status: 'deleted',
239+
});
240+
} else {
241+
sendTelemetryEvent(EventName.ENVIRONMENT_DELETE, undefined, {
242+
environmentType: 'conda',
243+
status: 'failed',
244+
});
245+
throw MultiStepAction.Cancel;
246+
}
247+
} else if (existingCondaAction === ExistingCondaAction.UseExisting) {
248+
sendTelemetryEvent(EventName.ENVIRONMENT_REUSE, undefined, {
249+
environmentType: 'conda',
250+
});
251+
return { path: getPrefixCondaEnvPath(workspace), workspaceFolder: workspace };
252+
}
253+
}
254+
214255
return withProgress(
215256
{
216257
location: ProgressLocation.Notification,
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import { WorkspaceFolder } from 'vscode';
5+
import { plainExec } from '../../../common/process/rawProcessApis';
6+
import { CreateEnv } from '../../../common/utils/localize';
7+
import { traceError, traceInfo } from '../../../logging';
8+
import { getPrefixCondaEnvPath, hasPrefixCondaEnv, showErrorMessageWithLogs } from '../common/commonUtils';
9+
10+
export async function deleteCondaEnvironment(
11+
workspace: WorkspaceFolder,
12+
interpreter: string,
13+
pathEnvVar: string,
14+
): Promise<boolean> {
15+
const condaEnvPath = getPrefixCondaEnvPath(workspace);
16+
const command = interpreter;
17+
const args = ['-m', 'conda', 'env', 'remove', '--prefix', condaEnvPath, '--yes'];
18+
try {
19+
traceInfo(`Deleting conda environment: ${condaEnvPath}`);
20+
traceInfo(`Running command: ${command} ${args.join(' ')}`);
21+
const result = await plainExec(command, args, { mergeStdOutErr: true }, { ...process.env, PATH: pathEnvVar });
22+
traceInfo(result.stdout);
23+
if (await hasPrefixCondaEnv(workspace)) {
24+
// If conda cannot delete files it will name the files as .conda_trash.
25+
// These need to be deleted manually.
26+
traceError(`Conda environment ${condaEnvPath} could not be deleted.`);
27+
traceError(`Please delete the environment manually: ${condaEnvPath}`);
28+
showErrorMessageWithLogs(CreateEnv.Conda.errorDeletingEnvironment);
29+
return false;
30+
}
31+
} catch (err) {
32+
showErrorMessageWithLogs(CreateEnv.Conda.errorDeletingEnvironment);
33+
traceError(`Deleting conda environment ${condaEnvPath} Failed with error: `, err);
34+
return false;
35+
}
36+
return true;
37+
}

src/client/pythonEnvironments/creation/provider/condaUtils.ts

Lines changed: 88 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License.
33

4-
import { CancellationToken, QuickPickItem, Uri } from 'vscode';
5-
import { Common } from '../../../browser/localize';
6-
import { Octicons } from '../../../common/constants';
7-
import { CreateEnv } from '../../../common/utils/localize';
4+
import * as path from 'path';
5+
import { CancellationToken, ProgressLocation, QuickPickItem, Uri, WorkspaceFolder } from 'vscode';
6+
import { Commands, Octicons } from '../../../common/constants';
7+
import { Common, CreateEnv } from '../../../common/utils/localize';
88
import { executeCommand } from '../../../common/vscodeApis/commandApis';
9-
import { showErrorMessage, showQuickPickWithBack } from '../../../common/vscodeApis/windowApis';
9+
import {
10+
MultiStepAction,
11+
showErrorMessage,
12+
showQuickPickWithBack,
13+
withProgress,
14+
} from '../../../common/vscodeApis/windowApis';
1015
import { traceLog } from '../../../logging';
1116
import { Conda } from '../../common/environmentManagers/conda';
17+
import { getPrefixCondaEnvPath, hasPrefixCondaEnv } from '../common/commonUtils';
18+
import { OSType, getEnvironmentVariable, getOSType } from '../../../common/utils/platform';
19+
import { deleteCondaEnvironment } from './condaDeleteUtils';
1220

1321
const RECOMMENDED_CONDA_PYTHON = '3.10';
1422

@@ -59,3 +67,78 @@ export async function pickPythonVersion(token?: CancellationToken): Promise<stri
5967

6068
return undefined;
6169
}
70+
71+
export function getPathEnvVariableForConda(condaBasePythonPath: string): string {
72+
const pathEnv = getEnvironmentVariable('PATH') || getEnvironmentVariable('Path') || '';
73+
if (getOSType() === OSType.Windows) {
74+
// On windows `conda.bat` is used, which adds the following bin directories to PATH
75+
// then launches `conda.exe` which is a stub to `python.exe -m conda`. Here, we are
76+
// instead using the `python.exe` that ships with conda to run a python script that
77+
// handles conda env creation and package installation.
78+
// See conda issue: https://github.com/conda/conda/issues/11399
79+
const root = path.dirname(condaBasePythonPath);
80+
const libPath1 = path.join(root, 'Library', 'bin');
81+
const libPath2 = path.join(root, 'Library', 'mingw-w64', 'bin');
82+
const libPath3 = path.join(root, 'Library', 'usr', 'bin');
83+
const libPath4 = path.join(root, 'bin');
84+
const libPath5 = path.join(root, 'Scripts');
85+
const libPath = [libPath1, libPath2, libPath3, libPath4, libPath5].join(path.delimiter);
86+
return `${libPath}${path.delimiter}${pathEnv}`;
87+
}
88+
return pathEnv;
89+
}
90+
91+
export async function deleteEnvironment(workspaceFolder: WorkspaceFolder, interpreter: string): Promise<boolean> {
92+
const condaEnvPath = getPrefixCondaEnvPath(workspaceFolder);
93+
return withProgress<boolean>(
94+
{
95+
location: ProgressLocation.Notification,
96+
title: `${CreateEnv.Conda.deletingEnvironmentProgress} ([${Common.showLogs}](command:${Commands.ViewOutput})): ${condaEnvPath}`,
97+
cancellable: false,
98+
},
99+
async () => deleteCondaEnvironment(workspaceFolder, interpreter, getPathEnvVariableForConda(interpreter)),
100+
);
101+
}
102+
103+
export enum ExistingCondaAction {
104+
Recreate,
105+
UseExisting,
106+
Create,
107+
}
108+
109+
export async function pickExistingCondaAction(
110+
workspaceFolder: WorkspaceFolder | undefined,
111+
): Promise<ExistingCondaAction> {
112+
if (workspaceFolder) {
113+
if (await hasPrefixCondaEnv(workspaceFolder)) {
114+
const items: QuickPickItem[] = [
115+
{ label: CreateEnv.Conda.recreate, description: CreateEnv.Conda.recreateDescription },
116+
{
117+
label: CreateEnv.Conda.useExisting,
118+
description: CreateEnv.Conda.useExistingDescription,
119+
},
120+
];
121+
122+
const selection = (await showQuickPickWithBack(
123+
items,
124+
{
125+
placeHolder: CreateEnv.Conda.existingCondaQuickPickPlaceholder,
126+
ignoreFocusOut: true,
127+
},
128+
undefined,
129+
)) as QuickPickItem | undefined;
130+
131+
if (selection?.label === CreateEnv.Conda.recreate) {
132+
return ExistingCondaAction.Recreate;
133+
}
134+
135+
if (selection?.label === CreateEnv.Conda.useExisting) {
136+
return ExistingCondaAction.UseExisting;
137+
}
138+
} else {
139+
return ExistingCondaAction.Create;
140+
}
141+
}
142+
143+
throw MultiStepAction.Cancel;
144+
}

src/test/pythonEnvironments/creation/provider/condaCreationProvider.unit.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ suite('Conda Creation provider tests', () => {
3535
let execObservableStub: sinon.SinonStub;
3636
let withProgressStub: sinon.SinonStub;
3737
let showErrorMessageWithLogsStub: sinon.SinonStub;
38+
let pickExistingCondaActionStub: sinon.SinonStub;
3839

3940
setup(() => {
4041
pickWorkspaceFolderStub = sinon.stub(wsSelect, 'pickWorkspaceFolder');
@@ -46,6 +47,9 @@ suite('Conda Creation provider tests', () => {
4647
showErrorMessageWithLogsStub = sinon.stub(commonUtils, 'showErrorMessageWithLogs');
4748
showErrorMessageWithLogsStub.resolves();
4849

50+
pickExistingCondaActionStub = sinon.stub(condaUtils, 'pickExistingCondaAction');
51+
pickExistingCondaActionStub.resolves(condaUtils.ExistingCondaAction.Create);
52+
4953
progressMock = typemoq.Mock.ofType<CreateEnvironmentProgress>();
5054
condaProvider = condaCreationProvider();
5155
});
@@ -77,6 +81,7 @@ suite('Conda Creation provider tests', () => {
7781
pickPythonVersionStub.resolves(undefined);
7882

7983
await assert.isRejected(condaProvider.createEnvironment());
84+
assert.isTrue(pickExistingCondaActionStub.calledOnce);
8085
});
8186

8287
test('Create conda environment', async () => {
@@ -136,6 +141,7 @@ suite('Conda Creation provider tests', () => {
136141
workspaceFolder: workspace1,
137142
});
138143
assert.isTrue(showErrorMessageWithLogsStub.notCalled);
144+
assert.isTrue(pickExistingCondaActionStub.calledOnce);
139145
});
140146

141147
test('Create conda environment failed', async () => {
@@ -188,6 +194,7 @@ suite('Conda Creation provider tests', () => {
188194
const result = await promise;
189195
assert.ok(result?.error);
190196
assert.isTrue(showErrorMessageWithLogsStub.calledOnce);
197+
assert.isTrue(pickExistingCondaActionStub.calledOnce);
191198
});
192199

193200
test('Create conda environment failed (non-zero exit code)', async () => {
@@ -245,5 +252,6 @@ suite('Conda Creation provider tests', () => {
245252
const result = await promise;
246253
assert.ok(result?.error);
247254
assert.isTrue(showErrorMessageWithLogsStub.calledOnce);
255+
assert.isTrue(pickExistingCondaActionStub.calledOnce);
248256
});
249257
});

0 commit comments

Comments
 (0)