Skip to content

Commit

Permalink
Show a prompt asking users if they want to create environment (micros…
Browse files Browse the repository at this point in the history
…oft#22071)

Criteria for showing prompts:
1. It has to be a workspace or multiroot workspace.
2. The workspace or workspace folder should not have ".venv" or ".conda"
environments.
3. The selected python should be a global python, i.e., there is no
workspace specific environment selected.
4. The workspace should **not** have any `pipfile`, `poetry.lock` etc.
5. The workspace should have files that match `*requirements*.txt` or
`requirements/*.txt` pattern.

There is a setting to enable this behavior:
`python.createEnvironment.trigger` and default is `off`

closes microsoft#21965
  • Loading branch information
karthiknadig authored and eleanorjboyd committed Oct 2, 2023
1 parent 01a0c53 commit 0d8a32d
Show file tree
Hide file tree
Showing 18 changed files with 711 additions and 2 deletions.
13 changes: 13 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,19 @@
"experimental"
]
},
"python.createEnvironment.trigger": {
"default": "off",
"markdownDescription": "%python.createEnvironment.trigger.description%",
"scope": "machine-overridable",
"type": "string",
"enum": [
"off",
"prompt"
],
"tags": [
"experimental"
]
},
"python.condaPath": {
"default": "",
"description": "%python.condaPath.description%",
Expand Down
1 change: 1 addition & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"python.command.python.launchTensorBoard.title": "Launch TensorBoard",
"python.command.python.refreshTensorBoard.title": "Refresh TensorBoard",
"python.createEnvironment.contentButton.description": "Show or hide Create Environment button in the editor for `requirements.txt` or other dependency files.",
"python.createEnvironment.trigger.description": "Detect if environment creation is required for the current project",
"python.menu.createNewFile.title": "Python File",
"python.editor.context.submenu.runPython": "Run Python",
"python.editor.context.submenu.runPythonInteractive": "Run in Interactive window",
Expand Down
1 change: 1 addition & 0 deletions src/client/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export namespace Commands {
export const ClearWorkspaceInterpreter = 'python.clearWorkspaceInterpreter';
export const Create_Environment = 'python.createEnvironment';
export const Create_Environment_Button = 'python.createEnvironment-button';
export const Create_Environment_Check = 'python.createEnvironmentCheck';
export const Create_Terminal = 'python.createTerminal';
export const Debug_In_Terminal = 'python.debugInTerminal';
export const Enable_SourceMap_Support = 'python.enableSourceMapSupport';
Expand Down
45 changes: 44 additions & 1 deletion src/client/common/persistentState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,46 @@ import {
import { cache } from './utils/decorators';
import { noop } from './utils/misc';

let _workspaceState: Memento | undefined;
const _workspaceKeys: string[] = [];
export function initializePersistentStateForTriggers(context: IExtensionContext) {
_workspaceState = context.workspaceState;
}

export function getWorkspaceStateValue<T>(key: string, defaultValue?: T): T | undefined {
if (!_workspaceState) {
throw new Error('Workspace state not initialized');
}
if (defaultValue === undefined) {
return _workspaceState.get<T>(key);
}
return _workspaceState.get<T>(key, defaultValue);
}

export async function updateWorkspaceStateValue<T>(key: string, value: T): Promise<void> {
if (!_workspaceState) {
throw new Error('Workspace state not initialized');
}
try {
_workspaceKeys.push(key);
await _workspaceState.update(key, value);
const after = getWorkspaceStateValue(key);
if (JSON.stringify(after) !== JSON.stringify(value)) {
await _workspaceState.update(key, undefined);
await _workspaceState.update(key, value);
traceError('Error while updating workspace state for key:', key);
}
} catch (ex) {
traceError(`Error while updating workspace state for key [${key}]:`, ex);
}
}

async function clearWorkspaceState(): Promise<void> {
if (_workspaceState !== undefined) {
await Promise.all(_workspaceKeys.map((key) => updateWorkspaceStateValue(key, undefined)));
}
}

export class PersistentState<T> implements IPersistentState<T> {
constructor(
public readonly storage: Memento,
Expand Down Expand Up @@ -93,7 +133,10 @@ export class PersistentStateFactory implements IPersistentStateFactory, IExtensi
) {}

public async activate(): Promise<void> {
this.cmdManager?.registerCommand(Commands.ClearStorage, this.cleanAllPersistentStates.bind(this));
this.cmdManager?.registerCommand(Commands.ClearStorage, async () => {
await clearWorkspaceState();
await this.cleanAllPersistentStates();
});
const globalKeysStorageDeprecated = this.createGlobalPersistentState(GLOBAL_PERSISTENT_KEYS_DEPRECATED, []);
const workspaceKeysStorageDeprecated = this.createWorkspacePersistentState(
WORKSPACE_PERSISTENT_KEYS_DEPRECATED,
Expand Down
9 changes: 9 additions & 0 deletions src/client/common/utils/localize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,15 @@ export namespace CreateEnv {
export const deletingEnvironmentProgress = l10n.t('Deleting existing ".conda" environment...');
export const errorDeletingEnvironment = l10n.t('Error while deleting existing ".conda" environment.');
}

export namespace Trigger {
export const workspaceTriggerMessage = l10n.t(
'A virtual environment is not currently selected for your Python interpreter. Would you like to create a virtual environment?',
);
export const createEnvironment = l10n.t('Create');
export const disableCheck = l10n.t('Disable');
export const disableCheckWorkspace = l10n.t('Disable (Workspace)');
}
}

export namespace ToolsExtensions {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ import { DebuggerTypeName } from '../../../constants';
import { DebugOptions, DebugPurpose, LaunchRequestArguments } from '../../../types';
import { BaseConfigurationResolver } from './base';
import { getProgram, IDebugEnvironmentVariablesService } from './helper';
import {
CreateEnvironmentCheckKind,
triggerCreateEnvironmentCheckNonBlocking,
} from '../../../../pythonEnvironments/creation/createEnvironmentTrigger';
import { sendTelemetryEvent } from '../../../../telemetry';
import { EventName } from '../../../../telemetry/constants';

@injectable()
export class LaunchConfigurationResolver extends BaseConfigurationResolver<LaunchRequestArguments> {
Expand Down Expand Up @@ -84,6 +90,8 @@ export class LaunchConfigurationResolver extends BaseConfigurationResolver<Launc
(item, pos) => debugConfiguration.debugOptions!.indexOf(item) === pos,
);
}
sendTelemetryEvent(EventName.ENVIRONMENT_CHECK_TRIGGER, undefined, { trigger: 'debug' });
triggerCreateEnvironmentCheckNonBlocking(CreateEnvironmentCheckKind.Workspace, workspaceFolder);
return debugConfiguration;
}

Expand Down
6 changes: 6 additions & 0 deletions src/client/debugger/extension/debugCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import { DebugPurpose, LaunchRequestArguments } from '../types';
import { IInterpreterService } from '../../interpreter/contracts';
import { noop } from '../../common/utils/misc';
import { getConfigurationsByUri } from './configuration/launch.json/launchJsonReader';
import {
CreateEnvironmentCheckKind,
triggerCreateEnvironmentCheckNonBlocking,
} from '../../pythonEnvironments/creation/createEnvironmentTrigger';

@injectable()
export class DebugCommands implements IExtensionSingleActivationService {
Expand All @@ -35,6 +39,8 @@ export class DebugCommands implements IExtensionSingleActivationService {
this.commandManager.executeCommand(Commands.TriggerEnvironmentSelection, file).then(noop, noop);
return;
}
sendTelemetryEvent(EventName.ENVIRONMENT_CHECK_TRIGGER, undefined, { trigger: 'debug-in-terminal' });
triggerCreateEnvironmentCheckNonBlocking(CreateEnvironmentCheckKind.File, file);
const config = await DebugCommands.getDebugConfiguration(file);
this.debugService.startDebugging(undefined, config);
}),
Expand Down
4 changes: 4 additions & 0 deletions src/client/extensionActivation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ import { DynamicPythonDebugConfigurationService } from './debugger/extension/con
import { IInterpreterQuickPick } from './interpreter/configuration/types';
import { registerInstallFormatterPrompt } from './providers/prompts/installFormatterPrompt';
import { registerAllCreateEnvironmentFeatures } from './pythonEnvironments/creation/registrations';
import { registerCreateEnvironmentTriggers } from './pythonEnvironments/creation/createEnvironmentTrigger';
import { initializePersistentStateForTriggers } from './common/persistentState';

export async function activateComponents(
// `ext` is passed to any extra activation funcs.
Expand Down Expand Up @@ -199,6 +201,8 @@ async function activateLegacy(ext: ExtensionState): Promise<ActivationResult> {
);

registerInstallFormatterPrompt(serviceContainer);
registerCreateEnvironmentTriggers(disposables);
initializePersistentStateForTriggers(ext.context);
}
}

Expand Down
113 changes: 113 additions & 0 deletions src/client/pythonEnvironments/creation/common/createEnvTriggerUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

import * as path from 'path';
import * as fsapi from 'fs-extra';
import { ConfigurationTarget, Uri, WorkspaceFolder } from 'vscode';
import { getPipRequirementsFiles } from '../provider/venvUtils';
import { getExtension } from '../../../common/vscodeApis/extensionsApi';
import { PVSC_EXTENSION_ID } from '../../../common/constants';
import { PythonExtension } from '../../../api/types';
import { traceVerbose } from '../../../logging';
import { getConfiguration } from '../../../common/vscodeApis/workspaceApis';
import { getWorkspaceStateValue, updateWorkspaceStateValue } from '../../../common/persistentState';

export const CREATE_ENV_TRIGGER_SETTING_PART = 'createEnvironment.trigger';
export const CREATE_ENV_TRIGGER_SETTING = `python.${CREATE_ENV_TRIGGER_SETTING_PART}`;

export async function fileContainsInlineDependencies(_uri: Uri): Promise<boolean> {
// This is a placeholder for the real implementation of inline dependencies support
// For now we don't detect anything. Once PEP-722/PEP-723 are accepted we can implement
// this properly.
return false;
}

export async function hasRequirementFiles(workspace: WorkspaceFolder): Promise<boolean> {
const files = await getPipRequirementsFiles(workspace);
const found = (files?.length ?? 0) > 0;
if (found) {
traceVerbose(`Found requirement files: ${workspace.uri.fsPath}`);
}
return found;
}

export async function hasKnownFiles(workspace: WorkspaceFolder): Promise<boolean> {
const filePaths: string[] = [
'poetry.lock',
'conda.yaml',
'environment.yaml',
'conda.yml',
'environment.yml',
'Pipfile',
'Pipfile.lock',
].map((fileName) => path.join(workspace.uri.fsPath, fileName));
const result = await Promise.all(filePaths.map((f) => fsapi.pathExists(f)));
const found = result.some((r) => r);
if (found) {
traceVerbose(`Found known files: ${workspace.uri.fsPath}`);
}
return found;
}

export async function isGlobalPythonSelected(workspace: WorkspaceFolder): Promise<boolean> {
const extension = getExtension<PythonExtension>(PVSC_EXTENSION_ID);
if (!extension) {
return false;
}
const extensionApi: PythonExtension = extension.exports as PythonExtension;
const interpreter = extensionApi.environments.getActiveEnvironmentPath(workspace.uri);
const details = await extensionApi.environments.resolveEnvironment(interpreter);
const isGlobal = details?.environment === undefined;
if (isGlobal) {
traceVerbose(`Selected python for [${workspace.uri.fsPath}] is [global] type: ${interpreter.path}`);
}
return isGlobal;
}

/**
* Checks the setting `python.createEnvironment.trigger` to see if we should perform the checks
* to prompt to create an environment.
* @export
* @returns : True if we should prompt to create an environment.
*/
export function shouldPromptToCreateEnv(): boolean {
const config = getConfiguration('python');
if (config) {
const value = config.get<string>(CREATE_ENV_TRIGGER_SETTING_PART, 'off');
return value !== 'off';
}

return getWorkspaceStateValue<string>(CREATE_ENV_TRIGGER_SETTING, 'off') !== 'off';
}

/**
* Sets `python.createEnvironment.trigger` to 'off' in the user settings.
*/
export function disableCreateEnvironmentTrigger(): void {
const config = getConfiguration('python');
if (config) {
config.update('createEnvironment.trigger', 'off', ConfigurationTarget.Global);
}
}

/**
* Sets trigger to 'off' in workspace persistent state. This disables trigger check
* for the current workspace only. In multi root case, it is disabled for all folders
* in the multi root workspace.
*/
export async function disableWorkspaceCreateEnvironmentTrigger(): Promise<void> {
await updateWorkspaceStateValue(CREATE_ENV_TRIGGER_SETTING, 'off');
}

let _alreadyCreateEnvCriteriaCheck = false;
/**
* Run-once wrapper function for the workspace check to prompt to create an environment.
* @returns : True if we should prompt to c environment.
*/
export function isCreateEnvWorkspaceCheckNotRun(): boolean {
if (_alreadyCreateEnvCriteriaCheck) {
return false;
}
_alreadyCreateEnvCriteriaCheck = true;
return true;
}
Loading

0 comments on commit 0d8a32d

Please sign in to comment.