Skip to content

Commit

Permalink
Add support to delete and re-create .conda environments (microsoft#21977
Browse files Browse the repository at this point in the history
  • Loading branch information
karthiknadig authored and eleanorjboyd committed Sep 13, 2023
1 parent 738c494 commit 666ae00
Show file tree
Hide file tree
Showing 8 changed files with 354 additions and 29 deletions.
16 changes: 14 additions & 2 deletions src/client/common/utils/localize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,8 +464,10 @@ export namespace CreateEnv {
export const error = l10n.t('Creating virtual environment failed with error.');
export const tomlExtrasQuickPickTitle = l10n.t('Select optional dependencies to install from pyproject.toml');
export const requirementsQuickPickTitle = l10n.t('Select dependencies to install');
export const recreate = l10n.t('Recreate');
export const recreateDescription = l10n.t('Delete existing ".venv" environment and create a new one');
export const recreate = l10n.t('Delete and Recreate');
export const recreateDescription = l10n.t(
'Delete existing ".venv" directory and create a new ".venv" environment',
);
export const useExisting = l10n.t('Use Existing');
export const useExistingDescription = l10n.t('Use existing ".venv" environment with no changes to it');
export const existingVenvQuickPickPlaceholder = l10n.t(
Expand All @@ -485,6 +487,16 @@ export namespace CreateEnv {
);
export const creating = l10n.t('Creating conda environment...');
export const providerDescription = l10n.t('Creates a `.conda` Conda environment in the current workspace');

export const recreate = l10n.t('Delete and Recreate');
export const recreateDescription = l10n.t('Delete existing ".conda" environment and create a new one');
export const useExisting = l10n.t('Use Existing');
export const useExistingDescription = l10n.t('Use existing ".conda" environment with no changes to it');
export const existingCondaQuickPickPlaceholder = l10n.t(
'Choose an option to handle the existing ".conda" environment',
);
export const deletingEnvironmentProgress = l10n.t('Deleting existing ".conda" environment...');
export const errorDeletingEnvironment = l10n.t('Error while deleting existing ".conda" environment.');
}
}

Expand Down
8 changes: 8 additions & 0 deletions src/client/pythonEnvironments/creation/common/commonUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,11 @@ export function getVenvExecutable(workspaceFolder: WorkspaceFolder): string {
}
return path.join(getVenvPath(workspaceFolder), 'bin', 'python');
}

export function getPrefixCondaEnvPath(workspaceFolder: WorkspaceFolder): string {
return path.join(workspaceFolder.uri.fsPath, '.conda');
}

export async function hasPrefixCondaEnv(workspaceFolder: WorkspaceFolder): Promise<boolean> {
return fs.pathExists(getPrefixCondaEnvPath(workspaceFolder));
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,18 @@ import { CreateEnvironmentProgress } from '../types';
import { pickWorkspaceFolder } from '../common/workspaceSelection';
import { execObservable } from '../../../common/process/rawProcessApis';
import { createDeferred } from '../../../common/utils/async';
import { getEnvironmentVariable, getOSType, OSType } from '../../../common/utils/platform';
import { getOSType, OSType } from '../../../common/utils/platform';
import { createCondaScript } from '../../../common/process/internal/scripts';
import { Common, CreateEnv } from '../../../common/utils/localize';
import { getCondaBaseEnv, pickPythonVersion } from './condaUtils';
import { showErrorMessageWithLogs } from '../common/commonUtils';
import {
ExistingCondaAction,
deleteEnvironment,
getCondaBaseEnv,
getPathEnvVariableForConda,
pickExistingCondaAction,
pickPythonVersion,
} from './condaUtils';
import { getPrefixCondaEnvPath, showErrorMessageWithLogs } from '../common/commonUtils';
import { MultiStepAction, MultiStepNode, withProgress } from '../../../common/vscodeApis/windowApis';
import { EventName } from '../../../telemetry/constants';
import { sendTelemetryEvent } from '../../../telemetry';
Expand Down Expand Up @@ -83,22 +90,7 @@ async function createCondaEnv(
});

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

let existingCondaAction: ExistingCondaAction | undefined;
const existingEnvStep = new MultiStepNode(
workspaceStep,
async (context?: MultiStepAction) => {
if (workspace && context === MultiStepAction.Continue) {
try {
existingCondaAction = await pickExistingCondaAction(workspace);
return MultiStepAction.Continue;
} catch (ex) {
if (ex === MultiStepAction.Back || ex === MultiStepAction.Cancel) {
return ex;
}
throw ex;
}
} else if (context === MultiStepAction.Back) {
return MultiStepAction.Back;
}
return MultiStepAction.Continue;
},
undefined,
);
workspaceStep.next = existingEnvStep;

let version: string | undefined;
const versionStep = new MultiStepNode(
workspaceStep,
Expand All @@ -204,13 +219,39 @@ async function createEnvironment(options?: CreateEnvironmentOptions): Promise<Cr
},
undefined,
);
workspaceStep.next = versionStep;
existingEnvStep.next = versionStep;

const action = await MultiStepNode.run(workspaceStep);
if (action === MultiStepAction.Back || action === MultiStepAction.Cancel) {
throw action;
}

if (workspace) {
if (existingCondaAction === ExistingCondaAction.Recreate) {
sendTelemetryEvent(EventName.ENVIRONMENT_DELETE, undefined, {
environmentType: 'conda',
status: 'triggered',
});
if (await deleteEnvironment(workspace, getExecutableCommand(conda))) {
sendTelemetryEvent(EventName.ENVIRONMENT_DELETE, undefined, {
environmentType: 'conda',
status: 'deleted',
});
} else {
sendTelemetryEvent(EventName.ENVIRONMENT_DELETE, undefined, {
environmentType: 'conda',
status: 'failed',
});
throw MultiStepAction.Cancel;
}
} else if (existingCondaAction === ExistingCondaAction.UseExisting) {
sendTelemetryEvent(EventName.ENVIRONMENT_REUSE, undefined, {
environmentType: 'conda',
});
return { path: getPrefixCondaEnvPath(workspace), workspaceFolder: workspace };
}
}

return withProgress(
{
location: ProgressLocation.Notification,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

import { WorkspaceFolder } from 'vscode';
import { plainExec } from '../../../common/process/rawProcessApis';
import { CreateEnv } from '../../../common/utils/localize';
import { traceError, traceInfo } from '../../../logging';
import { getPrefixCondaEnvPath, hasPrefixCondaEnv, showErrorMessageWithLogs } from '../common/commonUtils';

export async function deleteCondaEnvironment(
workspace: WorkspaceFolder,
interpreter: string,
pathEnvVar: string,
): Promise<boolean> {
const condaEnvPath = getPrefixCondaEnvPath(workspace);
const command = interpreter;
const args = ['-m', 'conda', 'env', 'remove', '--prefix', condaEnvPath, '--yes'];
try {
traceInfo(`Deleting conda environment: ${condaEnvPath}`);
traceInfo(`Running command: ${command} ${args.join(' ')}`);
const result = await plainExec(command, args, { mergeStdOutErr: true }, { ...process.env, PATH: pathEnvVar });
traceInfo(result.stdout);
if (await hasPrefixCondaEnv(workspace)) {
// If conda cannot delete files it will name the files as .conda_trash.
// These need to be deleted manually.
traceError(`Conda environment ${condaEnvPath} could not be deleted.`);
traceError(`Please delete the environment manually: ${condaEnvPath}`);
showErrorMessageWithLogs(CreateEnv.Conda.errorDeletingEnvironment);
return false;
}
} catch (err) {
showErrorMessageWithLogs(CreateEnv.Conda.errorDeletingEnvironment);
traceError(`Deleting conda environment ${condaEnvPath} Failed with error: `, err);
return false;
}
return true;
}
93 changes: 88 additions & 5 deletions src/client/pythonEnvironments/creation/provider/condaUtils.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

import { CancellationToken, QuickPickItem, Uri } from 'vscode';
import { Common } from '../../../browser/localize';
import { Octicons } from '../../../common/constants';
import { CreateEnv } from '../../../common/utils/localize';
import * as path from 'path';
import { CancellationToken, ProgressLocation, QuickPickItem, Uri, WorkspaceFolder } from 'vscode';
import { Commands, Octicons } from '../../../common/constants';
import { Common, CreateEnv } from '../../../common/utils/localize';
import { executeCommand } from '../../../common/vscodeApis/commandApis';
import { showErrorMessage, showQuickPickWithBack } from '../../../common/vscodeApis/windowApis';
import {
MultiStepAction,
showErrorMessage,
showQuickPickWithBack,
withProgress,
} from '../../../common/vscodeApis/windowApis';
import { traceLog } from '../../../logging';
import { Conda } from '../../common/environmentManagers/conda';
import { getPrefixCondaEnvPath, hasPrefixCondaEnv } from '../common/commonUtils';
import { OSType, getEnvironmentVariable, getOSType } from '../../../common/utils/platform';
import { deleteCondaEnvironment } from './condaDeleteUtils';

const RECOMMENDED_CONDA_PYTHON = '3.10';

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

return undefined;
}

export function getPathEnvVariableForConda(condaBasePythonPath: string): string {
const pathEnv = getEnvironmentVariable('PATH') || getEnvironmentVariable('Path') || '';
if (getOSType() === OSType.Windows) {
// On windows `conda.bat` is used, which adds the following bin directories to PATH
// then launches `conda.exe` which is a stub to `python.exe -m conda`. Here, we are
// instead using the `python.exe` that ships with conda to run a python script that
// handles conda env creation and package installation.
// See conda issue: https://github.com/conda/conda/issues/11399
const root = path.dirname(condaBasePythonPath);
const libPath1 = path.join(root, 'Library', 'bin');
const libPath2 = path.join(root, 'Library', 'mingw-w64', 'bin');
const libPath3 = path.join(root, 'Library', 'usr', 'bin');
const libPath4 = path.join(root, 'bin');
const libPath5 = path.join(root, 'Scripts');
const libPath = [libPath1, libPath2, libPath3, libPath4, libPath5].join(path.delimiter);
return `${libPath}${path.delimiter}${pathEnv}`;
}
return pathEnv;
}

export async function deleteEnvironment(workspaceFolder: WorkspaceFolder, interpreter: string): Promise<boolean> {
const condaEnvPath = getPrefixCondaEnvPath(workspaceFolder);
return withProgress<boolean>(
{
location: ProgressLocation.Notification,
title: `${CreateEnv.Conda.deletingEnvironmentProgress} ([${Common.showLogs}](command:${Commands.ViewOutput})): ${condaEnvPath}`,
cancellable: false,
},
async () => deleteCondaEnvironment(workspaceFolder, interpreter, getPathEnvVariableForConda(interpreter)),
);
}

export enum ExistingCondaAction {
Recreate,
UseExisting,
Create,
}

export async function pickExistingCondaAction(
workspaceFolder: WorkspaceFolder | undefined,
): Promise<ExistingCondaAction> {
if (workspaceFolder) {
if (await hasPrefixCondaEnv(workspaceFolder)) {
const items: QuickPickItem[] = [
{ label: CreateEnv.Conda.recreate, description: CreateEnv.Conda.recreateDescription },
{
label: CreateEnv.Conda.useExisting,
description: CreateEnv.Conda.useExistingDescription,
},
];

const selection = (await showQuickPickWithBack(
items,
{
placeHolder: CreateEnv.Conda.existingCondaQuickPickPlaceholder,
ignoreFocusOut: true,
},
undefined,
)) as QuickPickItem | undefined;

if (selection?.label === CreateEnv.Conda.recreate) {
return ExistingCondaAction.Recreate;
}

if (selection?.label === CreateEnv.Conda.useExisting) {
return ExistingCondaAction.UseExisting;
}
} else {
return ExistingCondaAction.Create;
}
}

throw MultiStepAction.Cancel;
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ suite('Conda Creation provider tests', () => {
let execObservableStub: sinon.SinonStub;
let withProgressStub: sinon.SinonStub;
let showErrorMessageWithLogsStub: sinon.SinonStub;
let pickExistingCondaActionStub: sinon.SinonStub;

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

pickExistingCondaActionStub = sinon.stub(condaUtils, 'pickExistingCondaAction');
pickExistingCondaActionStub.resolves(condaUtils.ExistingCondaAction.Create);

progressMock = typemoq.Mock.ofType<CreateEnvironmentProgress>();
condaProvider = condaCreationProvider();
});
Expand Down Expand Up @@ -77,6 +81,7 @@ suite('Conda Creation provider tests', () => {
pickPythonVersionStub.resolves(undefined);

await assert.isRejected(condaProvider.createEnvironment());
assert.isTrue(pickExistingCondaActionStub.calledOnce);
});

test('Create conda environment', async () => {
Expand Down Expand Up @@ -136,6 +141,7 @@ suite('Conda Creation provider tests', () => {
workspaceFolder: workspace1,
});
assert.isTrue(showErrorMessageWithLogsStub.notCalled);
assert.isTrue(pickExistingCondaActionStub.calledOnce);
});

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

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

0 comments on commit 666ae00

Please sign in to comment.