diff --git a/src/kernels/jupyter/interpreter/nbconvertInterpreterDependencyChecker.node.ts b/src/kernels/jupyter/interpreter/nbconvertInterpreterDependencyChecker.node.ts index 5393d76d669..dad3919244a 100644 --- a/src/kernels/jupyter/interpreter/nbconvertInterpreterDependencyChecker.node.ts +++ b/src/kernels/jupyter/interpreter/nbconvertInterpreterDependencyChecker.node.ts @@ -6,7 +6,7 @@ import { inject, injectable } from 'inversify'; import { SemVer } from 'semver'; import { CancellationToken } from 'vscode'; -import { parseSemVer } from '../../../platform/common/utils.node'; +import { parseSemVer } from '../../../platform/common/utils'; import { PythonEnvironment } from '../../../platform/pythonEnvironments/info'; import { ResourceSet } from '../../../platform/vscode-path/map'; import { JupyterCommands } from '../../../telemetry'; diff --git a/src/platform/common/constants.ts b/src/platform/common/constants.ts index 27beba97d0a..089cb2e0fe7 100644 --- a/src/platform/common/constants.ts +++ b/src/platform/common/constants.ts @@ -517,6 +517,7 @@ export enum Telemetry { UserInstalledPandas = 'DATASCIENCE.USER_INSTALLED_PANDAS', UserDidNotInstallJupyter = 'DATASCIENCE.USER_DID_NOT_INSTALL_JUPYTER', UserDidNotInstallPandas = 'DATASCIENCE.USER_DID_NOT_INSTALL_PANDAS', + FailedToInstallPandas = 'DATASCIENCE.FAILED_TO_INSTALL_PANDAS', OpenedInteractiveWindow = 'DATASCIENCE.OPENED_INTERACTIVE', OpenNotebookFailure = 'DS_INTERNAL.NATIVE.OPEN_NOTEBOOK_FAILURE', FindKernelForLocalConnection = 'DS_INTERNAL.FIND_KERNEL_FOR_LOCAL_CONNECTION', @@ -638,7 +639,9 @@ export enum Telemetry { FetchError = 'DS_INTERNAL.WEB_FETCH_ERROR', TerminalShellIdentification = 'TERMINAL_SHELL_IDENTIFICATION', TerminalEnvVariableExtraction = 'TERMINAL_ENV_VAR_EXTRACTION', - JupyterInstalled = 'JUPYTER_IS_INSTALLED' + JupyterInstalled = 'JUPYTER_IS_INSTALLED', + NoIdleKernel = 'DATASCIENCE.NO_IDLE_KERNEL', + NoActiveKernelSession = 'DATASCIENCE.NO_ACTIVE_KERNEL_SESSION' } export enum NativeKeyboardCommandTelemetry { diff --git a/src/platform/common/utils.node.ts b/src/platform/common/utils.node.ts index d61b4b5067d..f6fa89f4120 100644 --- a/src/platform/common/utils.node.ts +++ b/src/platform/common/utils.node.ts @@ -4,7 +4,6 @@ import * as path from '../../platform/vscode-path/path'; import * as fsExtra from 'fs-extra'; -import { SemVer, parse } from 'semver'; import { Uri } from 'vscode'; import { fsPathToUri } from '../vscode-path/utils'; import { IWorkspaceService } from './application/types'; @@ -71,14 +70,3 @@ export async function calculateWorkingDirectory( } return workingDir; } - -// For the given string parse it out to a SemVer or return undefined -export function parseSemVer(versionString: string): SemVer | undefined { - const versionMatch = /^\s*(\d+)\.(\d+)\.(.+)\s*$/.exec(versionString); - if (versionMatch && versionMatch.length > 2) { - const major = parseInt(versionMatch[1], 10); - const minor = parseInt(versionMatch[2], 10); - const build = parseInt(versionMatch[3], 10); - return parse(`${major}.${minor}.${build}`, true) ?? undefined; - } -} diff --git a/src/platform/common/utils.ts b/src/platform/common/utils.ts index 9c6f82221c0..37f745587b4 100644 --- a/src/platform/common/utils.ts +++ b/src/platform/common/utils.ts @@ -4,6 +4,7 @@ import type * as nbformat from '@jupyterlab/nbformat'; import * as uriPath from '../../platform/vscode-path/resources'; +import { SemVer, parse } from 'semver'; import { NotebookData, NotebookDocument, TextDocument, Uri, workspace } from 'vscode'; import { InteractiveWindowView, jupyterLanguageToMonacoLanguageMapping, JupyterNotebookView } from './constants'; import { traceError, traceInfo } from '../logging'; @@ -366,3 +367,14 @@ export function removeLinesFromFrontAndBack(code: string | string[]): string { const lines = Array.isArray(code) ? code : code.splitLines({ trim: false, removeEmptyEntries: false }); return removeLinesFromFrontAndBackNoConcat(lines).join('\n'); } + +// For the given string parse it out to a SemVer or return undefined +export function parseSemVer(versionString: string): SemVer | undefined { + const versionMatch = /^\s*(\d+)\.(\d+)\.(.+)\s*$/.exec(versionString); + if (versionMatch && versionMatch.length > 2) { + const major = parseInt(versionMatch[1], 10); + const minor = parseInt(versionMatch[2], 10); + const build = parseInt(versionMatch[3], 10); + return parse(`${major}.${minor}.${build}`, true) ?? undefined; + } +} diff --git a/src/platform/common/utils/localize.ts b/src/platform/common/utils/localize.ts index 168936fc12e..140a8535b24 100644 --- a/src/platform/common/utils/localize.ts +++ b/src/platform/common/utils/localize.ts @@ -1364,6 +1364,19 @@ export namespace DataScience { localize('DataScience.webNotSupported', `Operation not supported in web version of Jupyter Extension.`); export const validationErrorMessageForRemoteUrlProtocolNeedsToBeHttpOrHttps = () => localize('DataScience.validationErrorMessageForRemoteUrlProtocolNeedsToBeHttpOrHttps', 'Has to be http(s)'); + export const noIdleKernel = () => localize('DataScience.noIdleKernel', 'No idle kernel'); + export const noActiveKernelSession = () => + localize('DataScience.noActiveKernelSession', 'No active kernel session'); + export const failedToGetVersionOfPandas = () => + localize( + { key: 'DataScience.failedToGetVersionOfPandas', comment: ['{Locked="Pandas"}'] }, + 'Failed to get version of Pandas to use the Data Viewer' + ); + export const failedToInstallPandas = () => + localize( + { key: 'DataScience.failedToInstallPandas', comment: ['{Locked="Pandas"}'] }, + 'Failed to install Pandas to use the Data Viewer' + ); } export namespace Installer { diff --git a/src/telemetry.ts b/src/telemetry.ts index ca1cde81810..56711b0ae2e 100644 --- a/src/telemetry.ts +++ b/src/telemetry.ts @@ -502,6 +502,7 @@ export interface IEventNamePropertyMapping { [Telemetry.UserInstalledPandas]: never | undefined; [Telemetry.UserDidNotInstallJupyter]: never | undefined; [Telemetry.UserDidNotInstallPandas]: never | undefined; + [Telemetry.FailedToInstallPandas]: never | undefined; [Telemetry.PythonNotInstalled]: { action: | 'displayed' // Message displayed. @@ -1382,4 +1383,14 @@ export interface IEventNamePropertyMapping { * Total time take to copy the nb extensions folder. */ [Telemetry.IPyWidgetNbExtensionCopyTime]: never | undefined; + /** + * Useful when we need an active kernel in order to execute commands silently. + * Used by the data frame when attempting to install Pandas. + */ + [Telemetry.NoIdleKernel]: never | undefined; + /** + * Useful when we need an active kernel session in order to execute commands silently. + * Used by the data frame when attempting to install Pandas. + */ + [Telemetry.NoActiveKernelSession]: never | undefined; } diff --git a/src/test/datascience/data-viewing/dataViewerDependencyService.node.unit.test.ts b/src/test/datascience/data-viewing/dataViewerDependencyService.node.unit.test.ts new file mode 100644 index 00000000000..fa450f9cf79 --- /dev/null +++ b/src/test/datascience/data-viewing/dataViewerDependencyService.node.unit.test.ts @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { assert } from 'chai'; +import * as path from '../../../platform/vscode-path/path'; +import { SemVer } from 'semver'; +import { anything, deepEqual, instance, mock, verify, when } from 'ts-mockito'; +import { ApplicationShell } from '../../../platform/common/application/applicationShell'; +import { IApplicationShell } from '../../../platform/common/application/types'; +import { PythonExecutionFactory } from '../../../platform/common/process/pythonExecutionFactory.node'; +import { IPythonExecutionFactory, IPythonExecutionService } from '../../../platform/common/process/types.node'; +import { Common, DataScience } from '../../../platform/common/utils/localize'; +import { IInterpreterService } from '../../../platform/interpreter/contracts'; +import { PythonEnvironment } from '../../../platform/pythonEnvironments/info'; +import { ProductInstaller } from '../../../kernels/installer/productInstaller.node'; +import { IInstaller, Product } from '../../../kernels/installer/types'; +import { DataViewerDependencyService } from '../../../webviews/extension-side/dataviewer/dataViewerDependencyService.node'; +import { Uri } from 'vscode'; + +suite('DataScience - DataViewerDependencyService (Node)', () => { + let dependencyService: DataViewerDependencyService; + let appShell: IApplicationShell; + let pythonExecFactory: IPythonExecutionFactory; + let installer: IInstaller; + let interpreter: PythonEnvironment; + let interpreterService: IInterpreterService; + let pythonExecService: IPythonExecutionService; + setup(async () => { + interpreter = { + displayName: '', + uri: Uri.file(path.join('users', 'python', 'bin', 'python.exe')), + sysPrefix: '', + sysVersion: '', + version: new SemVer('3.3.3') + }; + pythonExecService = mock(); + installer = mock(ProductInstaller); + appShell = mock(ApplicationShell); + pythonExecFactory = mock(PythonExecutionFactory); + interpreterService = mock(); + + dependencyService = new DataViewerDependencyService( + instance(appShell), + instance(installer), + instance(pythonExecFactory), + instance(interpreterService), + false + ); + + when(interpreterService.getActiveInterpreter()).thenResolve(interpreter); + when(interpreterService.getActiveInterpreter(anything())).thenResolve(interpreter); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (instance(pythonExecService) as any).then = undefined; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (pythonExecService as any).then = undefined; + when(pythonExecFactory.createActivatedEnvironment(anything())).thenResolve(instance(pythonExecService)); + }); + test('All ok, if pandas is installed and version is > 1.20', async () => { + when( + pythonExecService.exec(deepEqual(['-c', 'import pandas;print(pandas.__version__)']), anything()) + ).thenResolve({ stdout: '0.30.0' }); + await dependencyService.checkAndInstallMissingDependenciesOnEnvironment(interpreter); + }); + test('Throw exception if pandas is installed and version is = 0.20', async () => { + when( + pythonExecService.exec(deepEqual(['-c', 'import pandas;print(pandas.__version__)']), anything()) + ).thenResolve({ stdout: '0.20.0' }); + + const promise = dependencyService.checkAndInstallMissingDependenciesOnEnvironment(interpreter); + + await assert.isRejected(promise, DataScience.pandasTooOldForViewingFormat().format('0.20.')); + }); + test('Throw exception if pandas is installed and version is < 0.20', async () => { + when( + pythonExecService.exec(deepEqual(['-c', 'import pandas;print(pandas.__version__)']), anything()) + ).thenResolve({ stdout: '0.10.0' }); + + const promise = dependencyService.checkAndInstallMissingDependenciesOnEnvironment(interpreter); + + await assert.isRejected(promise, DataScience.pandasTooOldForViewingFormat().format('0.10.')); + }); + test('Prompt to install pandas and install pandas', async () => { + when( + pythonExecService.exec(deepEqual(['-c', 'import pandas;print(pandas.__version__)']), anything()) + ).thenReject(new Error('Not Found')); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + when(appShell.showErrorMessage(anything(), anything(), anything())).thenResolve(Common.install() as any); + when(installer.install(Product.pandas, interpreter, anything())).thenResolve(); + + await dependencyService.checkAndInstallMissingDependenciesOnEnvironment(interpreter); + + verify( + appShell.showErrorMessage( + DataScience.pandasRequiredForViewing(), + deepEqual({ modal: true }), + Common.install() + ) + ).once(); + verify(installer.install(Product.pandas, interpreter, anything())).once(); + }); + test('Prompt to install pandas and throw error if user does not install pandas', async () => { + when( + pythonExecService.exec(deepEqual(['-c', 'import pandas;print(pandas.__version__)']), anything()) + ).thenReject(new Error('Not Found')); + when(appShell.showErrorMessage(anything(), anything(), anything())).thenResolve(); + + const promise = dependencyService.checkAndInstallMissingDependenciesOnEnvironment(interpreter); + + await assert.isRejected(promise, DataScience.pandasRequiredForViewing()); + verify( + appShell.showErrorMessage( + DataScience.pandasRequiredForViewing(), + deepEqual({ modal: true }), + Common.install() + ) + ).once(); + verify(installer.install(anything(), anything(), anything())).never(); + }); +}); diff --git a/src/test/datascience/data-viewing/dataViewerDependencyService.unit.test.ts b/src/test/datascience/data-viewing/dataViewerDependencyService.unit.test.ts index 7366f028a5b..8b00d5189ed 100644 --- a/src/test/datascience/data-viewing/dataViewerDependencyService.unit.test.ts +++ b/src/test/datascience/data-viewing/dataViewerDependencyService.unit.test.ts @@ -4,118 +4,136 @@ 'use strict'; import { assert } from 'chai'; -import * as path from '../../../platform/vscode-path/path'; -import { SemVer } from 'semver'; -import { anything, deepEqual, instance, mock, verify, when } from 'ts-mockito'; +import { anything, instance, mock, when } from 'ts-mockito'; import { ApplicationShell } from '../../../platform/common/application/applicationShell'; import { IApplicationShell } from '../../../platform/common/application/types'; -import { PythonExecutionFactory } from '../../../platform/common/process/pythonExecutionFactory.node'; -import { IPythonExecutionFactory, IPythonExecutionService } from '../../../platform/common/process/types.node'; +import { + DataViewerDependencyService, + getVersionOfPandasCommand +} from '../../../webviews/extension-side/dataviewer/dataViewerDependencyService'; +import { IKernel } from '../../../kernels/types'; import { Common, DataScience } from '../../../platform/common/utils/localize'; -import { IInterpreterService } from '../../../platform/interpreter/contracts'; -import { PythonEnvironment } from '../../../platform/pythonEnvironments/info'; -import { ProductInstaller } from '../../../kernels/installer/productInstaller.node'; -import { IInstaller, Product } from '../../../kernels/installer/types'; -import { DataViewerDependencyService } from '../../../webviews/extension-side/dataviewer/dataViewerDependencyService.node'; -import { Uri } from 'vscode'; +import * as helpers from '../../../kernels/helpers'; +import * as sinon from 'sinon'; suite('DataScience - DataViewerDependencyService', () => { let dependencyService: DataViewerDependencyService; let appShell: IApplicationShell; - let pythonExecFactory: IPythonExecutionFactory; - let installer: IInstaller; - let interpreter: PythonEnvironment; - let interpreterService: IInterpreterService; - let pythonExecService: IPythonExecutionService; + let kernel: IKernel; + setup(async () => { - interpreter = { - displayName: '', - uri: Uri.file(path.join('users', 'python', 'bin', 'python.exe')), - sysPrefix: '', - sysVersion: '', - version: new SemVer('3.3.3') - }; - pythonExecService = mock(); - installer = mock(ProductInstaller); appShell = mock(ApplicationShell); - pythonExecFactory = mock(PythonExecutionFactory); - interpreterService = mock(); - - dependencyService = new DataViewerDependencyService( - instance(appShell), - instance(installer), - instance(pythonExecFactory), - instance(interpreterService), - false - ); + kernel = instance(mock()); + dependencyService = new DataViewerDependencyService(instance(appShell), false); + }); - when(interpreterService.getActiveInterpreter()).thenResolve(interpreter); - when(interpreterService.getActiveInterpreter(anything())).thenResolve(interpreter); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (instance(pythonExecService) as any).then = undefined; + teardown(() => { + sinon.restore(); + }); + + test('What if there are no kernel sessions?', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any - (pythonExecService as any).then = undefined; - when(pythonExecFactory.createActivatedEnvironment(anything())).thenResolve(instance(pythonExecService)); + (kernel.session as any) = undefined; + + const resultPromise = dependencyService.checkAndInstallMissingDependenciesOnKernel(kernel); + + await assert.isRejected( + resultPromise, + DataScience.noActiveKernelSession(), + 'Failed to determine if there was an active kernel session' + ); }); + test('All ok, if pandas is installed and version is > 1.20', async () => { - when( - pythonExecService.exec(deepEqual(['-c', 'import pandas;print(pandas.__version__)']), anything()) - ).thenResolve({ stdout: '0.30.0' }); - await dependencyService.checkAndInstallMissingDependencies(interpreter); + const version = '3.3.3'; + + const stub = sinon.stub(helpers, 'executeSilently'); + stub.returns(Promise.resolve([{ ename: 'stdout', output_type: 'stream', text: version }])); + + const result = await dependencyService.checkAndInstallMissingDependenciesOnKernel(kernel); + assert.equal(result, undefined); + assert.deepEqual( + stub.getCalls().map((call) => call.lastArg), + [getVersionOfPandasCommand] + ); }); + + test('All ok, if pandas is installed and version is > 1.20, even if the command returns with a new line', async () => { + const version = '1.4.2\n'; + + const stub = sinon.stub(helpers, 'executeSilently'); + stub.returns(Promise.resolve([{ ename: 'stdout', output_type: 'stream', text: version }])); + + const result = await dependencyService.checkAndInstallMissingDependenciesOnKernel(kernel); + assert.equal(result, undefined); + assert.deepEqual( + stub.getCalls().map((call) => call.lastArg), + [getVersionOfPandasCommand] + ); + }); + test('Throw exception if pandas is installed and version is = 0.20', async () => { - when( - pythonExecService.exec(deepEqual(['-c', 'import pandas;print(pandas.__version__)']), anything()) - ).thenResolve({ stdout: '0.20.0' }); + const version = '0.20.0'; - const promise = dependencyService.checkAndInstallMissingDependencies(interpreter); + const stub = sinon.stub(helpers, 'executeSilently'); + stub.returns(Promise.resolve([{ ename: 'stdout', output_type: 'stream', text: version }])); - await assert.isRejected(promise, DataScience.pandasTooOldForViewingFormat().format('0.20.')); + const resultPromise = dependencyService.checkAndInstallMissingDependenciesOnKernel(kernel); + await assert.isRejected( + resultPromise, + DataScience.pandasTooOldForViewingFormat().format('0.20.'), + 'Failed to identify too old pandas' + ); + assert.deepEqual( + stub.getCalls().map((call) => call.lastArg), + [getVersionOfPandasCommand] + ); }); + test('Throw exception if pandas is installed and version is < 0.20', async () => { - when( - pythonExecService.exec(deepEqual(['-c', 'import pandas;print(pandas.__version__)']), anything()) - ).thenResolve({ stdout: '0.10.0' }); + const version = '0.10.0'; - const promise = dependencyService.checkAndInstallMissingDependencies(interpreter); + const stub = sinon.stub(helpers, 'executeSilently'); + stub.returns(Promise.resolve([{ ename: 'stdout', output_type: 'stream', text: version }])); - await assert.isRejected(promise, DataScience.pandasTooOldForViewingFormat().format('0.10.')); + const resultPromise = dependencyService.checkAndInstallMissingDependenciesOnKernel(kernel); + await assert.isRejected( + resultPromise, + DataScience.pandasTooOldForViewingFormat().format('0.10.'), + 'Failed to identify too old pandas' + ); + assert.deepEqual( + stub.getCalls().map((call) => call.lastArg), + [getVersionOfPandasCommand] + ); }); - test('Prompt to install pandas and install pandas', async () => { - when( - pythonExecService.exec(deepEqual(['-c', 'import pandas;print(pandas.__version__)']), anything()) - ).thenReject(new Error('Not Found')); + + test('Prompt to install pandas, then install pandas', async () => { + const stub = sinon.stub(helpers, 'executeSilently'); + stub.returns(Promise.resolve([{ ename: 'stdout', output_type: 'stream', text: '' }])); + // eslint-disable-next-line @typescript-eslint/no-explicit-any when(appShell.showErrorMessage(anything(), anything(), anything())).thenResolve(Common.install() as any); - when(installer.install(Product.pandas, interpreter, anything())).thenResolve(); - - await dependencyService.checkAndInstallMissingDependencies(interpreter); - - verify( - appShell.showErrorMessage( - DataScience.pandasRequiredForViewing(), - deepEqual({ modal: true }), - Common.install() - ) - ).once(); - verify(installer.install(Product.pandas, interpreter, anything())).once(); + + const resultPromise = dependencyService.checkAndInstallMissingDependenciesOnKernel(kernel); + assert.equal(await resultPromise, undefined); + assert.deepEqual( + stub.getCalls().map((call) => call.lastArg), + [getVersionOfPandasCommand, '%pip install pandas'] + ); }); + test('Prompt to install pandas and throw error if user does not install pandas', async () => { - when( - pythonExecService.exec(deepEqual(['-c', 'import pandas;print(pandas.__version__)']), anything()) - ).thenReject(new Error('Not Found')); + const stub = sinon.stub(helpers, 'executeSilently'); + stub.returns(Promise.resolve([{ ename: 'stdout', output_type: 'stream', text: '' }])); + when(appShell.showErrorMessage(anything(), anything(), anything())).thenResolve(); - const promise = dependencyService.checkAndInstallMissingDependencies(interpreter); - - await assert.isRejected(promise, DataScience.pandasRequiredForViewing()); - verify( - appShell.showErrorMessage( - DataScience.pandasRequiredForViewing(), - deepEqual({ modal: true }), - Common.install() - ) - ).once(); - verify(installer.install(anything(), anything(), anything())).never(); + const resultPromise = dependencyService.checkAndInstallMissingDependenciesOnKernel(kernel); + await assert.isRejected(resultPromise, DataScience.pandasRequiredForViewing()); + assert.deepEqual( + stub.getCalls().map((call) => call.lastArg), + [getVersionOfPandasCommand] + ); }); }); diff --git a/src/webviews/extension-side/dataviewer/dataViewerCommandRegistry.ts b/src/webviews/extension-side/dataviewer/dataViewerCommandRegistry.ts index 7eae4bbfd18..82b1a4b61b4 100644 --- a/src/webviews/extension-side/dataviewer/dataViewerCommandRegistry.ts +++ b/src/webviews/extension-side/dataviewer/dataViewerCommandRegistry.ts @@ -99,7 +99,10 @@ export class DataViewerCommandRegistry implements IExtensionSingleActivationServ this.debugService.activeDebugSession.configuration ); - pythonEnv && (await this.dataViewerDependencyService.checkAndInstallMissingDependencies(pythonEnv)); + pythonEnv && + (await this.dataViewerDependencyService.checkAndInstallMissingDependenciesOnEnvironment( + pythonEnv + )); } const variable = convertDebugProtocolVariableToIJupyterVariable( diff --git a/src/webviews/extension-side/dataviewer/dataViewerDependencyService.node.ts b/src/webviews/extension-side/dataviewer/dataViewerDependencyService.node.ts index 15003308e8d..1546f6d65f3 100644 --- a/src/webviews/extension-side/dataviewer/dataViewerDependencyService.node.ts +++ b/src/webviews/extension-side/dataviewer/dataViewerDependencyService.node.ts @@ -13,7 +13,7 @@ import { Cancellation, createPromiseFromCancellation } from '../../../platform/c import { traceWarning } from '../../../platform/logging'; import { IPythonExecutionFactory } from '../../../platform/common/process/types.node'; import { IsCodeSpace } from '../../../platform/common/types'; -import { parseSemVer } from '../../../platform/common/utils.node'; +import { parseSemVer } from '../../../platform/common/utils'; import { DataScience, Common } from '../../../platform/common/utils/localize'; import { IInterpreterService } from '../../../platform/interpreter/contracts'; import { PythonEnvironment } from '../../../platform/pythonEnvironments/info'; @@ -39,7 +39,11 @@ export class DataViewerDependencyService implements IDataViewerDependencyService @inject(IsCodeSpace) private isCodeSpace: boolean ) {} - public async checkAndInstallMissingDependencies(interpreter: PythonEnvironment): Promise { + public async checkAndInstallMissingDependenciesOnKernel(): Promise { + throw new Error('Not implemented'); + } + + public async checkAndInstallMissingDependenciesOnEnvironment(interpreter: PythonEnvironment): Promise { const tokenSource = new CancellationTokenSource(); try { const pandasVersion = await this.getVersionOfPandas(interpreter, tokenSource.token); diff --git a/src/webviews/extension-side/dataviewer/dataViewerDependencyService.ts b/src/webviews/extension-side/dataviewer/dataViewerDependencyService.ts new file mode 100644 index 00000000000..daaef5793c4 --- /dev/null +++ b/src/webviews/extension-side/dataviewer/dataViewerDependencyService.ts @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { inject, injectable } from 'inversify'; +import { SemVer } from 'semver'; +import { ProductNames } from '../../../kernels/installer/productNames'; +import { Product } from '../../../kernels/installer/types'; +import { IApplicationShell } from '../../../platform/common/application/types'; +import { traceWarning } from '../../../platform/logging'; +import { IsCodeSpace } from '../../../platform/common/types'; +import { parseSemVer } from '../../../platform/common/utils'; +import { DataScience, Common } from '../../../platform/common/utils/localize'; +import { EnvironmentType } from '../../../platform/pythonEnvironments/info'; +import { sendTelemetryEvent, Telemetry } from '../../../telemetry'; +import { IDataViewerDependencyService } from './types'; +import { IKernel } from '../../../kernels/types'; +import { executeSilently } from '../../../kernels/helpers'; + +export const minimumSupportedPandaVersion = '0.20.0'; +export const getVersionOfPandasCommand = + 'import pandas as _VSCODE_pandas;print(_VSCODE_pandas.__version__);del _VSCODE_pandas'; + +function isVersionOfPandaSupported(version: SemVer) { + return version.compare(minimumSupportedPandaVersion) > 0; +} + +/** + * Responsible for managing dependencies of a Data Viewer. + */ +@injectable() +export class DataViewerDependencyService implements IDataViewerDependencyService { + constructor( + @inject(IApplicationShell) private readonly applicationShell: IApplicationShell, + @inject(IsCodeSpace) private isCodeSpace: boolean + ) {} + + private packaging(kernel: IKernel): 'pip' | 'conda' { + const envType = kernel.kernelConnectionMetadata.interpreter?.envType; + const isConda = envType === EnvironmentType.Conda; + return isConda ? 'conda' : 'pip'; + } + + public async checkAndInstallMissingDependenciesOnEnvironment(): Promise { + throw new Error('Not implemented'); + } + + public async checkAndInstallMissingDependenciesOnKernel(kernel: IKernel): Promise { + const pandasVersion = await this.getVersionOfPandas(kernel); + + if (pandasVersion) { + if (isVersionOfPandaSupported(pandasVersion)) { + return; + } + sendTelemetryEvent(Telemetry.PandasTooOld); + // Warn user that we cannot start because pandas is too old. + const versionStr = `${pandasVersion.major}.${pandasVersion.minor}.${pandasVersion.build}`; + throw new Error(DataScience.pandasTooOldForViewingFormat().format(versionStr)); + } + + sendTelemetryEvent(Telemetry.PandasNotInstalled); + await this.installMissingDependencies(kernel); + } + + private async installMissingDependencies(kernel: IKernel): Promise { + if (!kernel.session) { + sendTelemetryEvent(Telemetry.NoActiveKernelSession); + throw new Error(DataScience.noActiveKernelSession()); + } + + sendTelemetryEvent(Telemetry.PythonModuleInstall, undefined, { + action: 'displayed', + moduleName: ProductNames.get(Product.pandas)! + }); + + const selection = this.isCodeSpace + ? Common.install() + : await this.applicationShell.showErrorMessage( + DataScience.pandasRequiredForViewing(), + { modal: true }, + Common.install() + ); + + // From https://ipython.readthedocs.io/en/stable/interactive/magics.html#magic-pip (%conda is here as well). + const command = `%${this.packaging(kernel)} install pandas`; + + if (selection === Common.install()) { + try { + await this.executeSilently(command, kernel); + sendTelemetryEvent(Telemetry.UserInstalledPandas); + } catch (e) { + console.log('Error installing pandas', e); + sendTelemetryEvent(Telemetry.FailedToInstallPandas); + throw new Error(DataScience.failedToInstallPandas()); + } + } else { + sendTelemetryEvent(Telemetry.UserDidNotInstallPandas); + throw new Error(DataScience.pandasRequiredForViewing()); + } + } + + private async getVersionOfPandas(kernel: IKernel): Promise { + try { + const outputs = await this.executeSilently(getVersionOfPandasCommand, kernel); + return outputs.map((text) => (text ? parseSemVer(text.toString()) : undefined)).find((item) => item); + } catch (e) { + traceWarning(DataScience.failedToGetVersionOfPandas(), e.message); + return; + } + } + + private async executeSilently(command: string, kernel: IKernel): Promise<(string | undefined)[]> { + if (!kernel.session) { + sendTelemetryEvent(Telemetry.NoActiveKernelSession); + throw new Error(DataScience.noActiveKernelSession()); + } + const outputs = await executeSilently(kernel.session, command); + const error = outputs.find((item) => item.output_type === 'error'); + if (error) { + traceWarning(DataScience.failedToGetVersionOfPandas(), error.message); + } + return outputs.map((item) => item.text?.toString()); + } +} diff --git a/src/webviews/extension-side/dataviewer/jupyterVariableDataProvider.ts b/src/webviews/extension-side/dataviewer/jupyterVariableDataProvider.ts index 4a493699006..62e5c737043 100644 --- a/src/webviews/extension-side/dataviewer/jupyterVariableDataProvider.ts +++ b/src/webviews/extension-side/dataviewer/jupyterVariableDataProvider.ts @@ -161,10 +161,8 @@ export class JupyterVariableDataProvider implements IJupyterVariableDataProvider // Postpone pre-req and variable initialization until data is requested. if (!this.initialized && this.variable) { this.initialized = true; - if (this._kernel?.kernelConnectionMetadata.interpreter && this.dependencyService) { - await this.dependencyService.checkAndInstallMissingDependencies( - this._kernel?.kernelConnectionMetadata.interpreter - ); + if (this._kernel?.kernelConnectionMetadata && this.dependencyService) { + await this.dependencyService.checkAndInstallMissingDependenciesOnKernel(this._kernel); } this.variable = await this.variableManager.getDataFrameInfo(this.variable, this._kernel); } diff --git a/src/webviews/extension-side/dataviewer/types.ts b/src/webviews/extension-side/dataviewer/types.ts index 20df843d3c3..ded6785bb61 100644 --- a/src/webviews/extension-side/dataviewer/types.ts +++ b/src/webviews/extension-side/dataviewer/types.ts @@ -7,8 +7,8 @@ import { IKernel } from '../../../kernels/types'; import { IJupyterVariable } from '../../../kernels/variables/types'; import { IDisposable } from '../../../platform/common/types'; import { SharedMessages } from '../../../messageTypes'; -import { PythonEnvironment } from '../../../platform/pythonEnvironments/info'; import { SliceOperationSource } from '../../../platform/telemetry/constants'; +import { PythonEnvironment } from '../../../platform/pythonEnvironments/info'; export const CellFetchAllLimit = 100000; export const CellFetchSizeFirst = 100000; @@ -130,5 +130,6 @@ export interface IJupyterVariableDataProviderFactory { export const IDataViewerDependencyService = Symbol('IDataViewerDependencyService'); export interface IDataViewerDependencyService { - checkAndInstallMissingDependencies(interpreter: PythonEnvironment): Promise; + checkAndInstallMissingDependenciesOnEnvironment(environment: PythonEnvironment): Promise; + checkAndInstallMissingDependenciesOnKernel(kernel: IKernel): Promise; } diff --git a/src/webviews/extension-side/serviceRegistry.web.ts b/src/webviews/extension-side/serviceRegistry.web.ts index 986caa03d2a..3eef632d50b 100644 --- a/src/webviews/extension-side/serviceRegistry.web.ts +++ b/src/webviews/extension-side/serviceRegistry.web.ts @@ -5,6 +5,7 @@ import { JupyterVariableDataProvider } from './dataviewer/jupyterVariableDataPro import { JupyterVariableDataProviderFactory } from './dataviewer/jupyterVariableDataProviderFactory'; import { IDataViewer, + IDataViewerDependencyService, IDataViewerFactory, IJupyterVariableDataProvider, IJupyterVariableDataProviderFactory @@ -15,6 +16,7 @@ import { DataViewerFactory } from './dataviewer/dataViewerFactory'; import { DataViewer } from './dataviewer/dataViewer'; import { IServiceManager } from '../../platform/ioc/types'; import { IExtensionSingleActivationService } from '../../platform/activation/types'; +import { DataViewerDependencyService } from './dataviewer/dataViewerDependencyService'; export function registerTypes(serviceManager: IServiceManager) { // Data viewer @@ -24,6 +26,10 @@ export function registerTypes(serviceManager: IServiceManager) { ); serviceManager.add(IDataViewer, DataViewer); serviceManager.addSingleton(IDataViewerFactory, DataViewerFactory); + serviceManager.addSingleton( + IDataViewerDependencyService, + DataViewerDependencyService + ); // Variables view serviceManager.addSingleton(INotebookWatcher, NotebookWatcher); diff --git a/src/webviews/webview-side/interactive-common/variableExplorer.tsx b/src/webviews/webview-side/interactive-common/variableExplorer.tsx index 7219647910c..95380e5bcb6 100644 --- a/src/webviews/webview-side/interactive-common/variableExplorer.tsx +++ b/src/webviews/webview-side/interactive-common/variableExplorer.tsx @@ -122,7 +122,6 @@ export class VariableExplorer extends React.Component this.props.isWeb} /> ) }, @@ -315,7 +314,7 @@ export class VariableExplorer extends React.Component boolean; } export class VariableExplorerButtonCellFormatter extends React.Component { @@ -32,7 +31,7 @@ export class VariableExplorerButtonCellFormatter extends React.Component