diff --git a/package.json b/package.json index 0f56394b7198..cb06f5e6a519 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ }, "publisher": "ms-python", "enabledApiProposals": [ + "contribEditorContentMenu", "quickPickSortByLabel", "envShellEvent", "testObserver" @@ -41,7 +42,7 @@ "theme": "dark" }, "engines": { - "vscode": "^1.76.0" + "vscode": "^1.77.0-20230309" }, "keywords": [ "python", @@ -1688,6 +1689,18 @@ "when": "!virtualWorkspace && shellExecutionSupported" } ], + "editor/content": [ + { + "group": "Python", + "command": "python.createEnvironment", + "when": "resourceLangId == pip-requirements && !virtualWorkspace && shellExecutionSupported" + }, + { + "group": "Python", + "command": "python.createEnvironment", + "when": "resourceFilename == pyproject.toml && pipInstallableToml && !virtualWorkspace && shellExecutionSupported" + } + ], "editor/context": [ { "command": "python.execInTerminal", diff --git a/package.nls.json b/package.nls.json index 2d0f005f61d5..b36b1b829609 100644 --- a/package.nls.json +++ b/package.nls.json @@ -1,7 +1,7 @@ { "python.command.python.sortImports.title": "Sort Imports", "python.command.python.startREPL.title": "Start REPL", - "python.command.python.createEnvironment.title": "Create Environment", + "python.command.python.createEnvironment.title": "Create Environment...", "python.command.python.createNewFile.title": "New Python File", "python.command.python.createTerminal.title": "Create Terminal", "python.command.python.execInTerminal.title": "Run Python File in Terminal", diff --git a/src/client/common/vscodeApis/workspaceApis.ts b/src/client/common/vscodeApis/workspaceApis.ts index 5db6752f7f9c..74200ba46924 100644 --- a/src/client/common/vscodeApis/workspaceApis.ts +++ b/src/client/common/vscodeApis/workspaceApis.ts @@ -43,3 +43,15 @@ export function onDidSaveTextDocument( ): vscode.Disposable { return vscode.workspace.onDidSaveTextDocument(listener, thisArgs, disposables); } + +export function getOpenTextDocuments(): readonly vscode.TextDocument[] { + return vscode.workspace.textDocuments; +} + +export function onDidOpenTextDocument(handler: (doc: vscode.TextDocument) => void): vscode.Disposable { + return vscode.workspace.onDidOpenTextDocument(handler); +} + +export function onDidChangeTextDocument(handler: (e: vscode.TextDocumentChangeEvent) => void): vscode.Disposable { + return vscode.workspace.onDidChangeTextDocument(handler); +} diff --git a/src/client/extensionActivation.ts b/src/client/extensionActivation.ts index af11da753242..4b2a41105d77 100644 --- a/src/client/extensionActivation.ts +++ b/src/client/extensionActivation.ts @@ -63,6 +63,7 @@ import { DynamicPythonDebugConfigurationService } from './debugger/extension/con import { registerCreateEnvironmentFeatures } from './pythonEnvironments/creation/createEnvApi'; import { IInterpreterQuickPick } from './interpreter/configuration/types'; import { registerInstallFormatterPrompt } from './providers/prompts/installFormatterPrompt'; +import { registerPyProjectTomlCreateEnvFeatures } from './pythonEnvironments/creation/pyprojectTomlCreateEnv'; export async function activateComponents( // `ext` is passed to any extra activation funcs. @@ -106,6 +107,7 @@ export function activateFeatures(ext: ExtensionState, _components: Components): ); const pathUtils = ext.legacyIOC.serviceContainer.get(IPathUtils); registerCreateEnvironmentFeatures(ext.disposables, interpreterQuickPick, interpreterPathService, pathUtils); + registerPyProjectTomlCreateEnvFeatures(ext.disposables); } /// ////////////////////////// diff --git a/src/client/pythonEnvironments/creation/provider/venvUtils.ts b/src/client/pythonEnvironments/creation/provider/venvUtils.ts index 76012e7f7aef..4c88deac5b45 100644 --- a/src/client/pythonEnvironments/creation/provider/venvUtils.ts +++ b/src/client/pythonEnvironments/creation/provider/venvUtils.ts @@ -101,6 +101,11 @@ async function pickRequirementsFiles(files: string[], token?: CancellationToken) return undefined; } +export function isPipInstallableToml(tomlContent: string): boolean { + const toml = tomlParse(tomlContent); + return tomlHasBuildSystem(toml); +} + export interface IPackageInstallSelection { installType: 'toml' | 'requirements' | 'none'; installItem?: string; diff --git a/src/client/pythonEnvironments/creation/pyprojectTomlCreateEnv.ts b/src/client/pythonEnvironments/creation/pyprojectTomlCreateEnv.ts new file mode 100644 index 000000000000..5ead37b80dc9 --- /dev/null +++ b/src/client/pythonEnvironments/creation/pyprojectTomlCreateEnv.ts @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { TextDocument, TextDocumentChangeEvent } from 'vscode'; +import { IDisposableRegistry } from '../../common/types'; +import { executeCommand } from '../../common/vscodeApis/commandApis'; +import { + onDidOpenTextDocument, + onDidChangeTextDocument, + getOpenTextDocuments, +} from '../../common/vscodeApis/workspaceApis'; +import { isPipInstallableToml } from './provider/venvUtils'; + +async function setPyProjectTomlContextKey(doc: TextDocument): Promise { + if (isPipInstallableToml(doc.getText())) { + await executeCommand('setContext', 'pipInstallableToml', true); + } else { + await executeCommand('setContext', 'pipInstallableToml', false); + } +} + +export function registerPyProjectTomlCreateEnvFeatures(disposables: IDisposableRegistry): void { + disposables.push( + onDidOpenTextDocument(async (doc: TextDocument) => { + if (doc.fileName.endsWith('pyproject.toml')) { + await setPyProjectTomlContextKey(doc); + } + }), + onDidChangeTextDocument(async (e: TextDocumentChangeEvent) => { + if (e.document.fileName.endsWith('pyproject.toml')) { + await setPyProjectTomlContextKey(e.document); + } + }), + ); + + getOpenTextDocuments().forEach(async (doc: TextDocument) => { + if (doc.fileName.endsWith('pyproject.toml')) { + await setPyProjectTomlContextKey(doc); + } + }); +} diff --git a/src/test/pythonEnvironments/creation/pyprojectTomlCreateEnv.unit.test.ts b/src/test/pythonEnvironments/creation/pyprojectTomlCreateEnv.unit.test.ts new file mode 100644 index 000000000000..3f19aa5775b3 --- /dev/null +++ b/src/test/pythonEnvironments/creation/pyprojectTomlCreateEnv.unit.test.ts @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import * as chaiAsPromised from 'chai-as-promised'; +import * as sinon from 'sinon'; +import * as typemoq from 'typemoq'; +import { assert, use as chaiUse } from 'chai'; +import { TextDocument, TextDocumentChangeEvent } from 'vscode'; +import * as cmdApis from '../../../client/common/vscodeApis/commandApis'; +import * as workspaceApis from '../../../client/common/vscodeApis/workspaceApis'; +import { IDisposableRegistry } from '../../../client/common/types'; +import { registerPyProjectTomlCreateEnvFeatures } from '../../../client/pythonEnvironments/creation/pyprojectTomlCreateEnv'; + +chaiUse(chaiAsPromised); + +class FakeDisposable { + public dispose() { + // Do nothing + } +} + +function getInstallableToml(): typemoq.IMock { + const pyprojectTomlPath = 'pyproject.toml'; + const pyprojectToml = typemoq.Mock.ofType(); + pyprojectToml.setup((p) => p.fileName).returns(() => pyprojectTomlPath); + pyprojectToml + .setup((p) => p.getText(typemoq.It.isAny())) + .returns( + () => + '[project]\nname = "spam"\nversion = "2020.0.0"\n[build-system]\nrequires = ["setuptools ~= 58.0", "cython ~= 0.29.0"]\n[project.optional-dependencies]\ntest = ["pytest"]\ndoc = ["sphinx", "furo"]', + ); + return pyprojectToml; +} + +function getNonInstallableToml(): typemoq.IMock { + const pyprojectTomlPath = 'pyproject.toml'; + const pyprojectToml = typemoq.Mock.ofType(); + pyprojectToml.setup((p) => p.fileName).returns(() => pyprojectTomlPath); + pyprojectToml + .setup((p) => p.getText(typemoq.It.isAny())) + .returns(() => '[project]\nname = "spam"\nversion = "2020.0.0"\n'); + return pyprojectToml; +} + +function getSomeFile(): typemoq.IMock { + const someFilePath = 'something.py'; + const someFile = typemoq.Mock.ofType(); + someFile.setup((p) => p.fileName).returns(() => someFilePath); + someFile.setup((p) => p.getText(typemoq.It.isAny())).returns(() => 'print("Hello World")'); + return someFile; +} + +suite('PyProject.toml Create Env Features', () => { + let executeCommandStub: sinon.SinonStub; + const disposables: IDisposableRegistry = []; + let getOpenTextDocumentsStub: sinon.SinonStub; + let onDidOpenTextDocumentStub: sinon.SinonStub; + let onDidChangeTextDocumentStub: sinon.SinonStub; + + setup(() => { + executeCommandStub = sinon.stub(cmdApis, 'executeCommand'); + getOpenTextDocumentsStub = sinon.stub(workspaceApis, 'getOpenTextDocuments'); + onDidOpenTextDocumentStub = sinon.stub(workspaceApis, 'onDidOpenTextDocument'); + onDidChangeTextDocumentStub = sinon.stub(workspaceApis, 'onDidChangeTextDocument'); + + onDidOpenTextDocumentStub.returns(new FakeDisposable()); + onDidChangeTextDocumentStub.returns(new FakeDisposable()); + }); + + teardown(() => { + sinon.restore(); + disposables.forEach((d) => d.dispose()); + }); + + test('Installable pyproject.toml is already open in the editor on extension activate', async () => { + const pyprojectToml = getInstallableToml(); + getOpenTextDocumentsStub.returns([pyprojectToml.object]); + + registerPyProjectTomlCreateEnvFeatures(disposables); + + assert.ok(executeCommandStub.calledOnceWithExactly('setContext', 'pipInstallableToml', true)); + }); + + test('Non installable pyproject.toml is already open in the editor on extension activate', async () => { + const pyprojectToml = getNonInstallableToml(); + getOpenTextDocumentsStub.returns([pyprojectToml.object]); + + registerPyProjectTomlCreateEnvFeatures(disposables); + + assert.ok(executeCommandStub.calledOnceWithExactly('setContext', 'pipInstallableToml', false)); + }); + + test('Some random file open in the editor on extension activate', async () => { + const someFile = getSomeFile(); + getOpenTextDocumentsStub.returns([someFile.object]); + + registerPyProjectTomlCreateEnvFeatures(disposables); + + assert.ok(executeCommandStub.notCalled); + }); + + test('Installable pyproject.toml is opened in the editor', async () => { + getOpenTextDocumentsStub.returns([]); + + let handler: (doc: TextDocument) => void = () => { + /* do nothing */ + }; + onDidOpenTextDocumentStub.callsFake((callback) => { + handler = callback; + return new FakeDisposable(); + }); + + const pyprojectToml = getInstallableToml(); + + registerPyProjectTomlCreateEnvFeatures(disposables); + handler(pyprojectToml.object); + + assert.ok(executeCommandStub.calledOnceWithExactly('setContext', 'pipInstallableToml', true)); + }); + + test('Non Installable pyproject.toml is opened in the editor', async () => { + getOpenTextDocumentsStub.returns([]); + + let handler: (doc: TextDocument) => void = () => { + /* do nothing */ + }; + onDidOpenTextDocumentStub.callsFake((callback) => { + handler = callback; + return new FakeDisposable(); + }); + + const pyprojectToml = getNonInstallableToml(); + + registerPyProjectTomlCreateEnvFeatures(disposables); + handler(pyprojectToml.object); + + assert.ok(executeCommandStub.calledOnceWithExactly('setContext', 'pipInstallableToml', false)); + }); + + test('Some random file is opened in the editor', async () => { + getOpenTextDocumentsStub.returns([]); + + let handler: (doc: TextDocument) => void = () => { + /* do nothing */ + }; + onDidOpenTextDocumentStub.callsFake((callback) => { + handler = callback; + return new FakeDisposable(); + }); + + const someFile = getSomeFile(); + + registerPyProjectTomlCreateEnvFeatures(disposables); + handler(someFile.object); + + assert.ok(executeCommandStub.notCalled); + }); + + test('Installable pyproject.toml is changed', async () => { + getOpenTextDocumentsStub.returns([]); + + let handler: (d: TextDocumentChangeEvent) => void = () => { + /* do nothing */ + }; + onDidChangeTextDocumentStub.callsFake((callback) => { + handler = callback; + return new FakeDisposable(); + }); + + const pyprojectToml = getInstallableToml(); + + registerPyProjectTomlCreateEnvFeatures(disposables); + handler({ contentChanges: [], document: pyprojectToml.object, reason: undefined }); + + assert.ok(executeCommandStub.calledOnceWithExactly('setContext', 'pipInstallableToml', true)); + }); + + test('Non Installable pyproject.toml is changed', async () => { + getOpenTextDocumentsStub.returns([]); + + let handler: (d: TextDocumentChangeEvent) => void = () => { + /* do nothing */ + }; + onDidChangeTextDocumentStub.callsFake((callback) => { + handler = callback; + return new FakeDisposable(); + }); + + const pyprojectToml = getNonInstallableToml(); + + registerPyProjectTomlCreateEnvFeatures(disposables); + handler({ contentChanges: [], document: pyprojectToml.object, reason: undefined }); + + assert.ok(executeCommandStub.calledOnceWithExactly('setContext', 'pipInstallableToml', false)); + }); + + test('Some random file is changed', async () => { + getOpenTextDocumentsStub.returns([]); + + let handler: (d: TextDocumentChangeEvent) => void = () => { + /* do nothing */ + }; + onDidChangeTextDocumentStub.callsFake((callback) => { + handler = callback; + return new FakeDisposable(); + }); + + const someFile = getSomeFile(); + + registerPyProjectTomlCreateEnvFeatures(disposables); + handler({ contentChanges: [], document: someFile.object, reason: undefined }); + + assert.ok(executeCommandStub.notCalled); + }); +});