diff --git a/package.nls.json b/package.nls.json index a88ba79a1fda..e3450302a6b1 100644 --- a/package.nls.json +++ b/package.nls.json @@ -133,6 +133,7 @@ "diagnostics.checkIsort5UpgradeGuide": "We found outdated configuration for sorting imports in this workspace. Check the [isort upgrade guide](https://aka.ms/AA9j5x4) to update your settings.", "diagnostics.yesUpdateLaunch": "Yes, update launch.json", "diagnostics.invalidTestSettings": "Your settings needs to be updated to change the setting \"python.unitTest.\" to \"python.testing.\", otherwise testing Python code using the extension may not work. Would you like to automatically update your settings now?", + "diagnostics.pylanceDefaultMessage": "The Python extension now includes Pylance to improve completions, code navigation, overall performance and much more! You can learn more about the update and learn to change your language server [here](https://aka.ms/new-python-bundle).\n\nRead Pylance’s license [here](https://marketplace.visualstudio.com/items/ms-python.vscode-pylance/license).", "Common.canceled": "Canceled", "Common.cancel": "Cancel", "Common.yesPlease": "Yes, please", diff --git a/src/client/application/diagnostics/checks/pylanceDefault.ts b/src/client/application/diagnostics/checks/pylanceDefault.ts new file mode 100644 index 000000000000..08b1ca2499e9 --- /dev/null +++ b/src/client/application/diagnostics/checks/pylanceDefault.ts @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// eslint-disable-next-line max-classes-per-file +import { inject, named } from 'inversify'; +import { DiagnosticSeverity } from 'vscode'; +import { IStartPage } from '../../../common/startPage/types'; +import { IDisposableRegistry, IExtensionContext, Resource } from '../../../common/types'; +import { Diagnostics, Common } from '../../../common/utils/localize'; +import { IServiceContainer } from '../../../ioc/types'; +import { BaseDiagnostic, BaseDiagnosticsService } from '../base'; +import { DiagnosticCodes } from '../constants'; +import { DiagnosticCommandPromptHandlerServiceId, MessageCommandPrompt } from '../promptHandler'; +import { DiagnosticScope, IDiagnostic, IDiagnosticHandlerService } from '../types'; + +export const PYLANCE_PROMPT_MEMENTO = 'pylanceDefaultPromptMemento'; + +export class PylanceDefaultDiagnostic extends BaseDiagnostic { + constructor(message: string, resource: Resource) { + super( + DiagnosticCodes.PylanceDefaultDiagnostic, + message, + DiagnosticSeverity.Information, + DiagnosticScope.Global, + resource, + ); + } +} + +export const PylanceDefaultDiagnosticServiceId = 'PylanceDefaultDiagnosticServiceId'; + +export class PylanceDefaultDiagnosticService extends BaseDiagnosticsService { + constructor( + @inject(IServiceContainer) serviceContainer: IServiceContainer, + @inject(IExtensionContext) private readonly context: IExtensionContext, + @inject(IStartPage) private readonly startPage: IStartPage, + @inject(IDiagnosticHandlerService) + @named(DiagnosticCommandPromptHandlerServiceId) + protected readonly messageService: IDiagnosticHandlerService, + @inject(IDisposableRegistry) disposableRegistry: IDisposableRegistry, + ) { + super([DiagnosticCodes.PylanceDefaultDiagnostic], serviceContainer, disposableRegistry, true); + } + + public async diagnose(resource: Resource): Promise { + if (!(await this.shouldShowPrompt())) { + return []; + } + + return [new PylanceDefaultDiagnostic(Diagnostics.pylanceDefaultMessage(), resource)]; + } + + protected async onHandle(diagnostics: IDiagnostic[]): Promise { + if (diagnostics.length === 0 || !this.canHandle(diagnostics[0])) { + return; + } + + const diagnostic = diagnostics[0]; + if (await this.filterService.shouldIgnoreDiagnostic(diagnostic.code)) { + return; + } + + const options = [{ prompt: Common.ok() }]; + + await this.messageService.handle(diagnostic, { + commandPrompts: options, + onClose: this.updateMemento.bind(this), + }); + } + + private async updateMemento() { + await this.context.globalState.update(PYLANCE_PROMPT_MEMENTO, true); + } + + private async shouldShowPrompt(): Promise { + const savedVersion: string | undefined = this.startPage.initialMementoValue; + const promptShown: boolean | undefined = this.context.globalState.get(PYLANCE_PROMPT_MEMENTO); + + // savedVersion being undefined means that this is the first time the user activates the extension, + // and we don't want to show the prompt to first-time users. + // We set PYLANCE_PROMPT_MEMENTO here to skip the prompt + // in case the user reloads the extension and savedVersion becomes set + if (savedVersion === undefined) { + await this.updateMemento(); + return false; + } + + // promptShown being undefined means that this is the first time we check if we should show the prompt. + return promptShown === undefined; + } +} diff --git a/src/client/application/diagnostics/constants.ts b/src/client/application/diagnostics/constants.ts index a5c8c69a53d3..f03564ff2c42 100644 --- a/src/client/application/diagnostics/constants.ts +++ b/src/client/application/diagnostics/constants.ts @@ -19,4 +19,5 @@ export enum DiagnosticCodes { ConsoleTypeDiagnostic = 'ConsoleTypeDiagnostic', ConfigPythonPathDiagnostic = 'ConfigPythonPathDiagnostic', UpgradeCodeRunnerDiagnostic = 'UpgradeCodeRunnerDiagnostic', + PylanceDefaultDiagnostic = 'PylanceDefaultDiagnostic', } diff --git a/src/client/application/diagnostics/serviceRegistry.ts b/src/client/application/diagnostics/serviceRegistry.ts index 1388a2bab248..1a513ff9255a 100644 --- a/src/client/application/diagnostics/serviceRegistry.ts +++ b/src/client/application/diagnostics/serviceRegistry.ts @@ -28,6 +28,7 @@ import { PowerShellActivationHackDiagnosticsService, PowerShellActivationHackDiagnosticsServiceId, } from './checks/powerShellActivation'; +import { PylanceDefaultDiagnosticService, PylanceDefaultDiagnosticServiceId } from './checks/pylanceDefault'; import { InvalidPythonInterpreterService, InvalidPythonInterpreterServiceId } from './checks/pythonInterpreter'; import { PythonPathDeprecatedDiagnosticService, @@ -92,6 +93,13 @@ export function registerTypes(serviceManager: IServiceManager, languageServerTyp UpgradeCodeRunnerDiagnosticService, UpgradeCodeRunnerDiagnosticServiceId, ); + + serviceManager.addSingleton( + IDiagnosticsService, + PylanceDefaultDiagnosticService, + PylanceDefaultDiagnosticServiceId, + ); + serviceManager.addSingleton(IDiagnosticsCommandFactory, DiagnosticsCommandFactory); serviceManager.addSingleton(IApplicationDiagnostics, ApplicationDiagnostics); diff --git a/src/client/common/startPage/startPage.ts b/src/client/common/startPage/startPage.ts index dae649bfc59b..d0cccf72d5b2 100644 --- a/src/client/common/startPage/startPage.ts +++ b/src/client/common/startPage/startPage.ts @@ -28,6 +28,8 @@ import { WebviewPanelHost } from './webviewPanelHost'; const startPageDir = path.join(EXTENSION_ROOT_DIR, 'out', 'startPage-ui', 'viewers'); +export const EXTENSION_VERSION_MEMENTO = 'extensionVersion'; + // Class that opens, disposes and handles messages and actions for the Python Extension Start Page. // It also runs when the extension activates. @injectable() @@ -39,6 +41,8 @@ export class StartPage extends WebviewPanelHost private actionTakenOnFirstTime = false; private firstTime = false; private webviewDidLoad = false; + public initialMementoValue: string | undefined = undefined; + constructor( @inject(IWebviewPanelProvider) provider: IWebviewPanelProvider, @inject(ICodeCssGenerator) cssGenerator: ICodeCssGenerator, @@ -66,6 +70,7 @@ export class StartPage extends WebviewPanelHost false, ); this.timer = new StopWatch(); + this.initialMementoValue = this.context.globalState.get(EXTENSION_VERSION_MEMENTO); } public async activate(): Promise { @@ -129,7 +134,7 @@ export class StartPage extends WebviewPanelHost sendTelemetryEvent(Telemetry.StartPageOpenBlankNotebook); this.setTelemetryFlags(); - const savedVersion: string | undefined = this.context.globalState.get('extensionVersion'); + const savedVersion: string | undefined = this.context.globalState.get(EXTENSION_VERSION_MEMENTO); if (savedVersion) { await this.commandManager.executeCommand( @@ -220,7 +225,7 @@ export class StartPage extends WebviewPanelHost // Public for testing public async extensionVersionChanged(): Promise { - const savedVersion: string | undefined = this.context.globalState.get('extensionVersion'); + const savedVersion: string | undefined = this.context.globalState.get(EXTENSION_VERSION_MEMENTO); const version: string = this.appEnvironment.packageJson.version; let shouldShowStartPage: boolean; @@ -239,7 +244,7 @@ export class StartPage extends WebviewPanelHost // savedVersion being undefined means this is the first time the user activates the extension. // if savedVersion != version, there was an update - await this.context.globalState.update('extensionVersion', version); + await this.context.globalState.update(EXTENSION_VERSION_MEMENTO, version); return shouldShowStartPage; } diff --git a/src/client/common/startPage/types.ts b/src/client/common/startPage/types.ts index ef50ad1f9189..639dca40232a 100644 --- a/src/client/common/startPage/types.ts +++ b/src/client/common/startPage/types.ts @@ -10,6 +10,7 @@ export type JSONArray = JSONValue[]; export const IStartPage = Symbol('IStartPage'); export interface IStartPage { + readonly initialMementoValue?: string; open(): Promise; extensionVersionChanged(): Promise; } diff --git a/src/client/common/utils/localize.ts b/src/client/common/utils/localize.ts index 05458ccc3db7..4fd7eca4ec0f 100644 --- a/src/client/common/utils/localize.ts +++ b/src/client/common/utils/localize.ts @@ -67,6 +67,10 @@ export namespace Diagnostics { 'diagnostics.checkIsort5UpgradeGuide', 'We found outdated configuration for sorting imports in this workspace. Check the [isort upgrade guide](https://aka.ms/AA9j5x4) to update your settings.', ); + export const pylanceDefaultMessage = localize( + 'diagnostics.pylanceDefaultMessage', + 'The Python extension now includes Pylance to improve completions, code navigation, overall performance and much more! You can learn more about the update and learn to change your language server [here](https://aka.ms/new-python-bundle).\n\nRead Pylance’s license [here](https://marketplace.visualstudio.com/items/ms-python.vscode-pylance/license).', + ); } export namespace Common { diff --git a/src/test/application/diagnostics/checks/pylanceDefault.unit.test.ts b/src/test/application/diagnostics/checks/pylanceDefault.unit.test.ts new file mode 100644 index 000000000000..518f46a023f0 --- /dev/null +++ b/src/test/application/diagnostics/checks/pylanceDefault.unit.test.ts @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import * as assert from 'assert'; +import { expect } from 'chai'; +import * as typemoq from 'typemoq'; +import { ExtensionContext } from 'vscode'; +import { BaseDiagnosticsService } from '../../../../client/application/diagnostics/base'; +import { + PylanceDefaultDiagnostic, + PylanceDefaultDiagnosticService, + PYLANCE_PROMPT_MEMENTO, +} from '../../../../client/application/diagnostics/checks/pylanceDefault'; +import { DiagnosticCodes } from '../../../../client/application/diagnostics/constants'; +import { MessageCommandPrompt } from '../../../../client/application/diagnostics/promptHandler'; +import { + IDiagnostic, + IDiagnosticFilterService, + IDiagnosticHandlerService, + IDiagnosticsService, +} from '../../../../client/application/diagnostics/types'; +import { IStartPage } from '../../../../client/common/startPage/types'; +import { IExtensionContext } from '../../../../client/common/types'; +import { Common, Diagnostics } from '../../../../client/common/utils/localize'; +import { IServiceContainer } from '../../../../client/ioc/types'; + +suite('Application Diagnostics - Pylance informational prompt', () => { + let serviceContainer: typemoq.IMock; + let diagnosticService: IDiagnosticsService; + let filterService: typemoq.IMock; + let messageHandler: typemoq.IMock>; + let startPage: typemoq.IMock; + let context: typemoq.IMock; + let memento: typemoq.IMock; + + setup(() => { + serviceContainer = typemoq.Mock.ofType(); + filterService = typemoq.Mock.ofType(); + messageHandler = typemoq.Mock.ofType>(); + startPage = typemoq.Mock.ofType(); + context = typemoq.Mock.ofType(); + memento = typemoq.Mock.ofType(); + + serviceContainer + .setup((s) => s.get(typemoq.It.isValue(IDiagnosticFilterService))) + .returns(() => filterService.object); + context.setup((c) => c.globalState).returns(() => memento.object); + + diagnosticService = new (class extends PylanceDefaultDiagnosticService { + // eslint-disable-next-line class-methods-use-this + public _clear() { + while (BaseDiagnosticsService.handledDiagnosticCodeKeys.length > 0) { + BaseDiagnosticsService.handledDiagnosticCodeKeys.shift(); + } + } + })(serviceContainer.object, context.object, startPage.object, messageHandler.object, []); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (diagnosticService as any)._clear(); + }); + + teardown(() => { + context.reset(); + memento.reset(); + }); + + function setupMementos(version?: string, promptShown?: boolean) { + startPage.setup((s) => s.initialMementoValue).returns(() => version); + memento.setup((m) => m.get(PYLANCE_PROMPT_MEMENTO)).returns(() => promptShown); + } + + test("Should display message if it's an existing installation of the extension and the prompt has not been shown yet", async () => { + setupMementos('1.0.0', undefined); + + const diagnostics = await diagnosticService.diagnose(undefined); + + assert.deepStrictEqual(diagnostics, [ + new PylanceDefaultDiagnostic(Diagnostics.pylanceDefaultMessage(), undefined), + ]); + }); + + test("Should return empty diagnostics if it's an existing installation of the extension and the prompt has been shown before", async () => { + setupMementos('1.0.0', true); + + const diagnostics = await diagnosticService.diagnose(undefined); + + assert.deepStrictEqual(diagnostics, []); + }); + + test("Should return empty diagnostics if it's a fresh installation of the extension", async () => { + setupMementos(undefined, undefined); + + const diagnostics = await diagnosticService.diagnose(undefined); + + assert.deepStrictEqual(diagnostics, []); + }); + + test('Should display a prompt when handling the diagnostic code', async () => { + const diagnostic = new PylanceDefaultDiagnostic(DiagnosticCodes.PylanceDefaultDiagnostic, undefined); + let messagePrompt: MessageCommandPrompt | undefined; + + messageHandler + .setup((f) => f.handle(typemoq.It.isValue(diagnostic), typemoq.It.isAny())) + .callback((_d, prompt: MessageCommandPrompt) => { + messagePrompt = prompt; + }) + .returns(() => Promise.resolve()) + .verifiable(typemoq.Times.once()); + + await diagnosticService.handle([diagnostic]); + + filterService.verifyAll(); + messageHandler.verifyAll(); + + assert.notDeepStrictEqual(messagePrompt, undefined); + assert.notDeepStrictEqual(messagePrompt!.onClose, undefined); + assert.deepStrictEqual(messagePrompt!.commandPrompts, [{ prompt: Common.ok() }]); + }); + + test('Should return empty diagnostics if the diagnostic code has been ignored', async () => { + const diagnostic = new PylanceDefaultDiagnostic(DiagnosticCodes.PylanceDefaultDiagnostic, undefined); + + filterService + .setup((f) => f.shouldIgnoreDiagnostic(typemoq.It.isValue(DiagnosticCodes.PylanceDefaultDiagnostic))) + .returns(() => Promise.resolve(true)) + .verifiable(typemoq.Times.once()); + + messageHandler.setup((f) => f.handle(typemoq.It.isAny(), typemoq.It.isAny())).verifiable(typemoq.Times.never()); + + await diagnosticService.handle([diagnostic]); + + filterService.verifyAll(); + messageHandler.verifyAll(); + }); + + test('PylanceDefaultDiagnosticService can handle PylanceDefaultDiagnostic diagnostics', async () => { + const diagnostic = typemoq.Mock.ofType(); + diagnostic + .setup((d) => d.code) + .returns(() => DiagnosticCodes.PylanceDefaultDiagnostic) + .verifiable(typemoq.Times.atLeastOnce()); + + const canHandle = await diagnosticService.canHandle(diagnostic.object); + + expect(canHandle).to.be.equal(true, 'Invalid value'); + diagnostic.verifyAll(); + }); + + test('PylanceDefaultDiagnosticService cannot handle non-PylanceDefaultDiagnostic diagnostics', async () => { + const diagnostic = typemoq.Mock.ofType(); + diagnostic + .setup((d) => d.code) + .returns(() => DiagnosticCodes.EnvironmentActivationInPowerShellWithBatchFilesNotSupportedDiagnostic) + .verifiable(typemoq.Times.atLeastOnce()); + + const canHandle = await diagnosticService.canHandle(diagnostic.object); + + expect(canHandle).to.be.equal(false, 'Invalid value'); + diagnostic.verifyAll(); + }); +}); diff --git a/src/test/startPage/startPage.unit.test.ts b/src/test/startPage/startPage.unit.test.ts index fd42dfd3944b..85799f349e82 100644 --- a/src/test/startPage/startPage.unit.test.ts +++ b/src/test/startPage/startPage.unit.test.ts @@ -38,7 +38,6 @@ suite('StartPage tests', () => { const dummySettings = new PythonSettings(undefined, new MockAutoSelectionService()); function setupVersions(savedVersion: string, actualVersion: string) { - context.setup((c) => c.globalState).returns(() => memento.object); memento.setup((m) => m.get(typemoq.It.isAnyString())).returns(() => savedVersion); memento .setup((m) => m.update(typemoq.It.isAnyString(), typemoq.It.isAnyString())) @@ -50,7 +49,6 @@ suite('StartPage tests', () => { } function reset() { - context.reset(); memento.reset(); appEnvironment.reset(); } @@ -69,6 +67,7 @@ suite('StartPage tests', () => { appEnvironment = typemoq.Mock.ofType(); memento = typemoq.Mock.ofType(); + context.setup((c) => c.globalState).returns(() => memento.object); configuration.setup((cs) => cs.getSettings(undefined)).returns(() => dummySettings); startPage = new StartPage( diff --git a/src/test/startPage/startPageIocContainer.ts b/src/test/startPage/startPageIocContainer.ts index 00af74000c56..abba95cc6606 100644 --- a/src/test/startPage/startPageIocContainer.ts +++ b/src/test/startPage/startPageIocContainer.ts @@ -15,6 +15,7 @@ import { ConfigurationChangeEvent, Disposable, EventEmitter, + ExtensionContext, FileSystemWatcher, Uri, WorkspaceFolder, @@ -249,7 +250,9 @@ export class StartPageIocContainer extends UnitTestIocContainer { this.serviceManager.add(IInstallationChannelManager, InstallationChannelManager); + const mockMemento = TypeMoq.Mock.ofType(); const mockExtensionContext = TypeMoq.Mock.ofType(); + mockExtensionContext.setup((m) => m.globalState).returns(() => mockMemento.object); mockExtensionContext.setup((m) => m.globalStoragePath).returns(() => os.tmpdir()); mockExtensionContext.setup((m) => m.extensionPath).returns(() => this.extensionRootPath || os.tmpdir()); this.serviceManager.addSingletonInstance(IExtensionContext, mockExtensionContext.object);