Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
91 changes: 91 additions & 0 deletions src/client/application/diagnostics/checks/pylanceDefault.ts
Original file line number Diff line number Diff line change
@@ -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<MessageCommandPrompt>,
@inject(IDisposableRegistry) disposableRegistry: IDisposableRegistry,
) {
super([DiagnosticCodes.PylanceDefaultDiagnostic], serviceContainer, disposableRegistry, true);
}

public async diagnose(resource: Resource): Promise<IDiagnostic[]> {
if (!(await this.shouldShowPrompt())) {
return [];
}

return [new PylanceDefaultDiagnostic(Diagnostics.pylanceDefaultMessage(), resource)];
}

protected async onHandle(diagnostics: IDiagnostic[]): Promise<void> {
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<boolean> {
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;
}
Comment thread
kimadeline marked this conversation as resolved.

// promptShown being undefined means that this is the first time we check if we should show the prompt.
return promptShown === undefined;
}
}
1 change: 1 addition & 0 deletions src/client/application/diagnostics/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ export enum DiagnosticCodes {
ConsoleTypeDiagnostic = 'ConsoleTypeDiagnostic',
ConfigPythonPathDiagnostic = 'ConfigPythonPathDiagnostic',
UpgradeCodeRunnerDiagnostic = 'UpgradeCodeRunnerDiagnostic',
PylanceDefaultDiagnostic = 'PylanceDefaultDiagnostic',
}
8 changes: 8 additions & 0 deletions src/client/application/diagnostics/serviceRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
PowerShellActivationHackDiagnosticsService,
PowerShellActivationHackDiagnosticsServiceId,
} from './checks/powerShellActivation';
import { PylanceDefaultDiagnosticService, PylanceDefaultDiagnosticServiceId } from './checks/pylanceDefault';
import { InvalidPythonInterpreterService, InvalidPythonInterpreterServiceId } from './checks/pythonInterpreter';
import {
PythonPathDeprecatedDiagnosticService,
Expand Down Expand Up @@ -92,6 +93,13 @@ export function registerTypes(serviceManager: IServiceManager, languageServerTyp
UpgradeCodeRunnerDiagnosticService,
UpgradeCodeRunnerDiagnosticServiceId,
);

serviceManager.addSingleton<IDiagnosticsService>(
IDiagnosticsService,
PylanceDefaultDiagnosticService,
PylanceDefaultDiagnosticServiceId,
);

serviceManager.addSingleton<IDiagnosticsCommandFactory>(IDiagnosticsCommandFactory, DiagnosticsCommandFactory);
serviceManager.addSingleton<IApplicationDiagnostics>(IApplicationDiagnostics, ApplicationDiagnostics);

Expand Down
11 changes: 8 additions & 3 deletions src/client/common/startPage/startPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -39,6 +41,8 @@ export class StartPage extends WebviewPanelHost<IStartPageMapping>
private actionTakenOnFirstTime = false;
private firstTime = false;
private webviewDidLoad = false;
public initialMementoValue: string | undefined = undefined;

constructor(
@inject(IWebviewPanelProvider) provider: IWebviewPanelProvider,
@inject(ICodeCssGenerator) cssGenerator: ICodeCssGenerator,
Expand Down Expand Up @@ -66,6 +70,7 @@ export class StartPage extends WebviewPanelHost<IStartPageMapping>
false,
);
this.timer = new StopWatch();
this.initialMementoValue = this.context.globalState.get(EXTENSION_VERSION_MEMENTO);
}

public async activate(): Promise<void> {
Expand Down Expand Up @@ -129,7 +134,7 @@ export class StartPage extends WebviewPanelHost<IStartPageMapping>
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(
Expand Down Expand Up @@ -220,7 +225,7 @@ export class StartPage extends WebviewPanelHost<IStartPageMapping>

// Public for testing
public async extensionVersionChanged(): Promise<boolean> {
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;

Expand All @@ -239,7 +244,7 @@ export class StartPage extends WebviewPanelHost<IStartPageMapping>

// 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;
}

Expand Down
1 change: 1 addition & 0 deletions src/client/common/startPage/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type JSONArray = JSONValue[];

export const IStartPage = Symbol('IStartPage');
export interface IStartPage {
readonly initialMementoValue?: string;
open(): Promise<void>;
extensionVersionChanged(): Promise<boolean>;
}
Expand Down
4 changes: 4 additions & 0 deletions src/client/common/utils/localize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
162 changes: 162 additions & 0 deletions src/test/application/diagnostics/checks/pylanceDefault.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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<IServiceContainer>;
let diagnosticService: IDiagnosticsService;
let filterService: typemoq.IMock<IDiagnosticFilterService>;
let messageHandler: typemoq.IMock<IDiagnosticHandlerService<MessageCommandPrompt>>;
let startPage: typemoq.IMock<IStartPage>;
let context: typemoq.IMock<IExtensionContext>;
let memento: typemoq.IMock<ExtensionContext['globalState']>;

setup(() => {
serviceContainer = typemoq.Mock.ofType<IServiceContainer>();
filterService = typemoq.Mock.ofType<IDiagnosticFilterService>();
messageHandler = typemoq.Mock.ofType<IDiagnosticHandlerService<MessageCommandPrompt>>();
startPage = typemoq.Mock.ofType<IStartPage>();
context = typemoq.Mock.ofType<IExtensionContext>();
memento = typemoq.Mock.ofType<ExtensionContext['globalState']>();

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());
Comment thread
kimadeline marked this conversation as resolved.

await diagnosticService.handle([diagnostic]);

filterService.verifyAll();
messageHandler.verifyAll();
});

test('PylanceDefaultDiagnosticService can handle PylanceDefaultDiagnostic diagnostics', async () => {
const diagnostic = typemoq.Mock.ofType<IDiagnostic>();
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<IDiagnostic>();
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();
});
});
3 changes: 1 addition & 2 deletions src/test/startPage/startPage.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
Expand All @@ -50,7 +49,6 @@ suite('StartPage tests', () => {
}

function reset() {
context.reset();
memento.reset();
appEnvironment.reset();
}
Expand All @@ -69,6 +67,7 @@ suite('StartPage tests', () => {
appEnvironment = typemoq.Mock.ofType<IApplicationEnvironment>();
memento = typemoq.Mock.ofType<ExtensionContext['globalState']>();

context.setup((c) => c.globalState).returns(() => memento.object);
configuration.setup((cs) => cs.getSettings(undefined)).returns(() => dummySettings);

startPage = new StartPage(
Expand Down
Loading