Skip to content

Commit 9baedf7

Browse files
authored
Trigger env creation prompt on pip install in terminal with global environment (#23247)
Closes #23246
1 parent a54173d commit 9baedf7

15 files changed

+816
-11
lines changed

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@
2424
"quickPickItemTooltip",
2525
"terminalDataWriteEvent",
2626
"terminalExecuteCommandEvent",
27-
"contribIssueReporter"
27+
"contribIssueReporter",
28+
"terminalShellIntegration"
2829
],
2930
"author": {
3031
"name": "Microsoft Corporation"
@@ -45,7 +46,7 @@
4546
"theme": "dark"
4647
},
4748
"engines": {
48-
"vscode": "^1.86.0"
49+
"vscode": "^1.89.0-20240415"
4950
},
5051
"enableTelemetry": false,
5152
"keywords": [

src/client/common/experiments/groups.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,9 @@ export enum EnableTestAdapterRewrite {
2424
export enum RecommendTensobardExtension {
2525
experiment = 'pythonRecommendTensorboardExt',
2626
}
27+
28+
// Experiment to enable triggering venv creation when users install with `pip`
29+
// in a global environment
30+
export enum CreateEnvOnPipInstallTrigger {
31+
experiment = 'pythonCreateEnvOnPipInstall',
32+
}

src/client/common/utils/localize.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,5 +523,9 @@ export namespace CreateEnv {
523523
export const createEnvironment = l10n.t('Create');
524524
export const disableCheck = l10n.t('Disable');
525525
export const disableCheckWorkspace = l10n.t('Disable (Workspace)');
526+
527+
export const globalPipInstallTriggerMessage = l10n.t(
528+
'You may have installed Python packages into your global environment, which can cause conflicts between package versions. Would you like to create a virtual environment to isolate your dependencies?',
529+
);
526530
}
527531
}

src/client/common/vscodeApis/windowApis.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
Disposable,
1919
QuickPickItemButtonEvent,
2020
Uri,
21+
TerminalShellExecutionStartEvent,
2122
} from 'vscode';
2223
import { createDeferred, Deferred } from '../utils/async';
2324

@@ -104,6 +105,10 @@ export function onDidChangeActiveTextEditor(handler: (e: TextEditor | undefined)
104105
return window.onDidChangeActiveTextEditor(handler);
105106
}
106107

108+
export function onDidStartTerminalShellExecution(handler: (e: TerminalShellExecutionStartEvent) => void): Disposable {
109+
return window.onDidStartTerminalShellExecution(handler);
110+
}
111+
107112
export enum MultiStepAction {
108113
Back = 'Back',
109114
Cancel = 'Cancel',

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ async function getWorkspacesForQuickPick(workspaces: readonly WorkspaceFolder[])
3232
export interface PickWorkspaceFolderOptions {
3333
allowMultiSelect?: boolean;
3434
token?: CancellationToken;
35+
preSelectedWorkspace?: WorkspaceFolder;
3536
}
3637

3738
export async function pickWorkspaceFolder(
@@ -52,6 +53,15 @@ export async function pickWorkspaceFolder(
5253
return undefined;
5354
}
5455

56+
if (options?.preSelectedWorkspace) {
57+
if (context === MultiStepAction.Back) {
58+
// In this case there is no Quick Pick shown, should just go to previous
59+
throw MultiStepAction.Back;
60+
}
61+
62+
return options.preSelectedWorkspace;
63+
}
64+
5565
if (workspaces.length === 1) {
5666
if (context === MultiStepAction.Back) {
5767
// In this case there is no Quick Pick shown, should just go to previous

src/client/pythonEnvironments/creation/createEnvApi.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
} from './proposed.createEnvApis';
2121
import { sendTelemetryEvent } from '../../telemetry';
2222
import { EventName } from '../../telemetry/constants';
23+
import { CreateEnvironmentOptionsInternal } from './types';
2324

2425
class CreateEnvironmentProviders {
2526
private _createEnvProviders: CreateEnvironmentProvider[] = [];
@@ -64,7 +65,9 @@ export function registerCreateEnvironmentFeatures(
6465
disposables.push(
6566
registerCommand(
6667
Commands.Create_Environment,
67-
(options?: CreateEnvironmentOptions): Promise<CreateEnvironmentResult | undefined> => {
68+
(
69+
options?: CreateEnvironmentOptions & CreateEnvironmentOptionsInternal,
70+
): Promise<CreateEnvironmentResult | undefined> => {
6871
const providers = _createEnvironmentProviders.getAll();
6972
return handleCreateEnvironmentCommand(providers, options);
7073
},

src/client/pythonEnvironments/creation/createEnvironment.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
EnvironmentWillCreateEvent,
1818
EnvironmentDidCreateEvent,
1919
} from './proposed.createEnvApis';
20+
import { CreateEnvironmentOptionsInternal } from './types';
2021

2122
const onCreateEnvironmentStartedEvent = new EventEmitter<EnvironmentWillCreateEvent>();
2223
const onCreateEnvironmentExitedEvent = new EventEmitter<EnvironmentDidCreateEvent>();
@@ -55,7 +56,7 @@ export function getCreationEvents(): {
5556

5657
async function createEnvironment(
5758
provider: CreateEnvironmentProvider,
58-
options: CreateEnvironmentOptions,
59+
options: CreateEnvironmentOptions & CreateEnvironmentOptionsInternal,
5960
): Promise<CreateEnvironmentResult | undefined> {
6061
let result: CreateEnvironmentResult | undefined;
6162
let err: Error | undefined;
@@ -83,14 +84,21 @@ interface CreateEnvironmentProviderQuickPickItem extends QuickPickItem {
8384

8485
async function showCreateEnvironmentQuickPick(
8586
providers: readonly CreateEnvironmentProvider[],
86-
options?: CreateEnvironmentOptions,
87+
options?: CreateEnvironmentOptions & CreateEnvironmentOptionsInternal,
8788
): Promise<CreateEnvironmentProvider | undefined> {
8889
const items: CreateEnvironmentProviderQuickPickItem[] = providers.map((p) => ({
8990
label: p.name,
9091
description: p.description,
9192
id: p.id,
9293
}));
9394

95+
if (options?.providerId) {
96+
const provider = providers.find((p) => p.id === options.providerId);
97+
if (provider) {
98+
return provider;
99+
}
100+
}
101+
94102
let selectedItem: CreateEnvironmentProviderQuickPickItem | CreateEnvironmentProviderQuickPickItem[] | undefined;
95103

96104
if (options?.showBackButton) {
@@ -119,7 +127,9 @@ async function showCreateEnvironmentQuickPick(
119127
return undefined;
120128
}
121129

122-
function getOptionsWithDefaults(options?: CreateEnvironmentOptions): CreateEnvironmentOptions {
130+
function getOptionsWithDefaults(
131+
options?: CreateEnvironmentOptions & CreateEnvironmentOptionsInternal,
132+
): CreateEnvironmentOptions & CreateEnvironmentOptionsInternal {
123133
return {
124134
installPackages: true,
125135
ignoreSourceControl: true,
@@ -131,7 +141,7 @@ function getOptionsWithDefaults(options?: CreateEnvironmentOptions): CreateEnvir
131141

132142
export async function handleCreateEnvironmentCommand(
133143
providers: readonly CreateEnvironmentProvider[],
134-
options?: CreateEnvironmentOptions,
144+
options?: CreateEnvironmentOptions & CreateEnvironmentOptionsInternal,
135145
): Promise<CreateEnvironmentResult | undefined> {
136146
const optionsWithDefaults = getOptionsWithDefaults(options);
137147
let selectedProvider: CreateEnvironmentProvider | undefined;
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { Disposable, TerminalShellExecutionStartEvent } from 'vscode';
2+
import { CreateEnvOnPipInstallTrigger } from '../../common/experiments/groups';
3+
import { inExperiment } from '../common/externalDependencies';
4+
import {
5+
disableCreateEnvironmentTrigger,
6+
disableWorkspaceCreateEnvironmentTrigger,
7+
isGlobalPythonSelected,
8+
shouldPromptToCreateEnv,
9+
} from './common/createEnvTriggerUtils';
10+
import { getWorkspaceFolder, getWorkspaceFolders } from '../../common/vscodeApis/workspaceApis';
11+
import { CreateEnv } from '../../common/utils/localize';
12+
import { traceError, traceInfo } from '../../logging';
13+
import { executeCommand } from '../../common/vscodeApis/commandApis';
14+
import { Commands, PVSC_EXTENSION_ID } from '../../common/constants';
15+
import { CreateEnvironmentResult } from './proposed.createEnvApis';
16+
import { onDidStartTerminalShellExecution, showWarningMessage } from '../../common/vscodeApis/windowApis';
17+
import { sendTelemetryEvent } from '../../telemetry';
18+
import { EventName } from '../../telemetry/constants';
19+
20+
export function registerTriggerForPipInTerminal(disposables: Disposable[]): void {
21+
if (!shouldPromptToCreateEnv() || !inExperiment(CreateEnvOnPipInstallTrigger.experiment)) {
22+
return;
23+
}
24+
25+
const folders = getWorkspaceFolders();
26+
if (!folders || folders.length === 0) {
27+
return;
28+
}
29+
30+
const createEnvironmentTriggered: Map<string, boolean> = new Map();
31+
folders.forEach((workspaceFolder) => {
32+
createEnvironmentTriggered.set(workspaceFolder.uri.fsPath, false);
33+
});
34+
35+
disposables.push(
36+
onDidStartTerminalShellExecution(async (e: TerminalShellExecutionStartEvent) => {
37+
const workspaceFolder = getWorkspaceFolder(e.shellIntegration.cwd);
38+
if (
39+
workspaceFolder &&
40+
!createEnvironmentTriggered.get(workspaceFolder.uri.fsPath) &&
41+
(await isGlobalPythonSelected(workspaceFolder))
42+
) {
43+
if (e.execution.commandLine.isTrusted && e.execution.commandLine.value.startsWith('pip install')) {
44+
createEnvironmentTriggered.set(workspaceFolder.uri.fsPath, true);
45+
sendTelemetryEvent(EventName.ENVIRONMENT_TERMINAL_GLOBAL_PIP);
46+
const selection = await showWarningMessage(
47+
CreateEnv.Trigger.globalPipInstallTriggerMessage,
48+
CreateEnv.Trigger.createEnvironment,
49+
CreateEnv.Trigger.disableCheckWorkspace,
50+
CreateEnv.Trigger.disableCheck,
51+
);
52+
if (selection === CreateEnv.Trigger.createEnvironment) {
53+
try {
54+
const result: CreateEnvironmentResult = await executeCommand(Commands.Create_Environment, {
55+
workspaceFolder,
56+
providerId: `${PVSC_EXTENSION_ID}:venv`,
57+
});
58+
if (result.path) {
59+
traceInfo('CreateEnv Trigger - Environment created: ', result.path);
60+
traceInfo(
61+
`CreateEnv Trigger - Running: ${
62+
result.path
63+
} -m ${e.execution.commandLine.value.trim()}`,
64+
);
65+
e.shellIntegration.executeCommand(
66+
`${result.path} -m ${e.execution.commandLine.value}`.trim(),
67+
);
68+
}
69+
} catch (error) {
70+
traceError('CreateEnv Trigger - Error while creating environment: ', error);
71+
}
72+
} else if (selection === CreateEnv.Trigger.disableCheck) {
73+
disableCreateEnvironmentTrigger();
74+
} else if (selection === CreateEnv.Trigger.disableCheckWorkspace) {
75+
disableWorkspaceCreateEnvironmentTrigger();
76+
}
77+
}
78+
}
79+
}),
80+
);
81+
}

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { execObservable } from '../../../common/process/rawProcessApis';
99
import { createDeferred } from '../../../common/utils/async';
1010
import { Common, CreateEnv } from '../../../common/utils/localize';
1111
import { traceError, traceInfo, traceLog, traceVerbose } from '../../../logging';
12-
import { CreateEnvironmentProgress } from '../types';
12+
import { CreateEnvironmentOptionsInternal, CreateEnvironmentProgress } from '../types';
1313
import { pickWorkspaceFolder } from '../common/workspaceSelection';
1414
import { IInterpreterQuickPick } from '../../../interpreter/configuration/types';
1515
import { EnvironmentType, PythonEnvironment } from '../../info';
@@ -152,13 +152,18 @@ async function createVenv(
152152
export class VenvCreationProvider implements CreateEnvironmentProvider {
153153
constructor(private readonly interpreterQuickPick: IInterpreterQuickPick) {}
154154

155-
public async createEnvironment(options?: CreateEnvironmentOptions): Promise<CreateEnvironmentResult | undefined> {
155+
public async createEnvironment(
156+
options?: CreateEnvironmentOptions & CreateEnvironmentOptionsInternal,
157+
): Promise<CreateEnvironmentResult | undefined> {
156158
let workspace: WorkspaceFolder | undefined;
157159
const workspaceStep = new MultiStepNode(
158160
undefined,
159161
async (context?: MultiStepAction) => {
160162
try {
161-
workspace = (await pickWorkspaceFolder(undefined, context)) as WorkspaceFolder | undefined;
163+
workspace = (await pickWorkspaceFolder(
164+
{ preSelectedWorkspace: options?.workspaceFolder },
165+
context,
166+
)) as WorkspaceFolder | undefined;
162167
} catch (ex) {
163168
if (ex === MultiStepAction.Back || ex === MultiStepAction.Cancel) {
164169
return ex;

src/client/pythonEnvironments/creation/registrations.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { IInterpreterQuickPick } from '../../interpreter/configuration/types';
66
import { IInterpreterService } from '../../interpreter/contracts';
77
import { registerCreateEnvironmentFeatures } from './createEnvApi';
88
import { registerCreateEnvironmentButtonFeatures } from './createEnvButtonContext';
9+
import { registerTriggerForPipInTerminal } from './globalPipInTerminalTrigger';
910
import { registerInstalledPackagesDiagnosticsProvider } from './installedPackagesDiagnostic';
1011
import { registerPyProjectTomlFeatures } from './pyProjectTomlContext';
1112

@@ -20,4 +21,5 @@ export function registerAllCreateEnvironmentFeatures(
2021
registerCreateEnvironmentButtonFeatures(disposables);
2122
registerPyProjectTomlFeatures(disposables);
2223
registerInstalledPackagesDiagnosticsProvider(disposables, interpreterService);
24+
registerTriggerForPipInTerminal(disposables);
2325
}
Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License
33

4-
import { Progress } from 'vscode';
4+
import { Progress, WorkspaceFolder } from 'vscode';
55

66
export interface CreateEnvironmentProgress extends Progress<{ message?: string; increment?: number }> {}
7+
8+
export interface CreateEnvironmentOptionsInternal {
9+
workspaceFolder?: WorkspaceFolder;
10+
providerId?: string;
11+
}

src/client/telemetry/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ export enum EventName {
112112

113113
ENVIRONMENT_CHECK_TRIGGER = 'ENVIRONMENT.CHECK.TRIGGER',
114114
ENVIRONMENT_CHECK_RESULT = 'ENVIRONMENT.CHECK.RESULT',
115+
ENVIRONMENT_TERMINAL_GLOBAL_PIP = 'ENVIRONMENT.TERMINAL.GLOBAL_PIP',
115116
}
116117

117118
export enum PlatformErrors {

src/client/telemetry/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2030,6 +2030,13 @@ export interface IEventNamePropertyMapping {
20302030
[EventName.ENVIRONMENT_CHECK_RESULT]: {
20312031
result: 'criteria-met' | 'criteria-not-met' | 'already-ran' | 'turned-off' | 'no-uri';
20322032
};
2033+
/**
2034+
* Telemetry event sent when `pip install` was called from a global env in a shell where shell inegration is supported.
2035+
*/
2036+
/* __GDPR__
2037+
"environment.terminal.global_pip" : { "owner": "karthiknadig" }
2038+
*/
2039+
[EventName.ENVIRONMENT_TERMINAL_GLOBAL_PIP]: never | undefined;
20332040
/* __GDPR__
20342041
"query-expfeature" : {
20352042
"owner": "luabud",

0 commit comments

Comments
 (0)