From 7d702c3ca8b0e157fd25860751b87470b6431625 Mon Sep 17 00:00:00 2001 From: Rich Chiodo Date: Fri, 8 Feb 2019 11:58:22 -0800 Subject: [PATCH] Preliminary live share support (just a simple cell running mirrored on guest) (#4325) * First commit * Webpanellistener abstraction and new general purpose live share post office * Logic in place * Fix callbacks for non live share mode * Get codelens to show up in liveshare guest * Cells visible, but crashing in execution * Remove the IJupyterExecutionFactory idea * Fix local non shared case to work again * Fix text documents being found on unopened docs * New idea for jupyter server. * Local mode working again * Closer to having code lens work on guest * Communication getting to point of waiting for results * More communication in place * Add getSysInfo to jupyterServer and make executeSilently return data * Trying to get responses to parse * Fix splicing * Get single cell to run across live share * Fix hygiene errors * Abstracting liveshare api for tests * Fix History Simple text to pass. * Get notebook tests to pass * Add the feature flag and fixup new loc strings * Add news entry * Fix code lens/watcher tests. Fix capitilization issue * Rename JupyterExecutionBase and JupyterServerBase back * Some review feedback * More code review feedback. Fix send_info. * Move regexes to the common location * Fix compile-webviews --- news/1 Enhancements/3581.md | 1 + package-lock.json | 5 + package.json | 7 + package.nls.json | 7 + src/client/common/application/types.ts | 11 +- src/client/common/liveshare/liveshare.ts | 46 ++++ src/client/common/serviceRegistry.ts | 3 + src/client/common/types.ts | 1 + src/client/common/utils/localize.ts | 7 + src/client/common/utils/misc.ts | 18 +- src/client/datascience/commandBroker.ts | 81 ++++++ src/client/datascience/constants.ts | 45 ++++ src/client/datascience/datascience.ts | 68 +++-- .../editor-integration/codelensprovider.ts | 15 +- .../editor-integration/codewatcher.ts | 11 +- src/client/datascience/history.ts | 124 ++++----- .../datascience/historyMessageListener.ts | 49 ++++ .../datascience/historycommandlistener.ts | 7 +- .../datascience/jupyter/jupyterConnection.ts | 8 +- .../datascience/jupyter/jupyterExecution.ts | 100 ++++---- .../jupyter/jupyterExecutionFactory.ts | 113 +++++++++ .../datascience/jupyter/jupyterServer.ts | 222 +++++++++------- .../jupyter/jupyterServerFactory.ts | 136 ++++++++++ .../datascience/jupyter/jupyterSession.ts | 6 +- .../jupyter/jupyterSessionManager.ts | 24 +- .../liveshare/guestJupyterExecution.ts | 139 ++++++++++ .../jupyter/liveshare/guestJupyterServer.ts | 238 ++++++++++++++++++ .../jupyter/liveshare/hostJupyterExecution.ts | 164 ++++++++++++ .../jupyter/liveshare/hostJupyterServer.ts | 190 ++++++++++++++ .../jupyter/liveshare/roleBasedFactory.ts | 62 +++++ .../datascience/jupyter/liveshare/types.ts | 48 ++++ .../datascience/jupyter/liveshare/utils.ts | 45 ++++ .../datascience/liveshare/postOffice.ts | 237 +++++++++++++++++ src/client/datascience/serviceRegistry.ts | 9 +- src/client/datascience/types.ts | 15 +- .../history-react/inputHistory.ts | 2 +- .../datascience/dataScienceIocContainer.ts | 14 +- .../codelensprovider.unit.test.ts | 8 +- .../codewatcher.unit.test.ts | 3 +- src/test/datascience/execution.unit.test.ts | 18 +- .../datascience/history.functional.test.tsx | 2 +- .../historyCommandListener.unit.test.ts | 2 +- src/test/datascience/mockLiveShare.ts | 17 ++ .../datascience/notebook.functional.test.ts | 48 ++-- src/test/unittests/serviceRegistry.ts | 4 +- tsconfig.datascience-ui.json | 5 +- 46 files changed, 2060 insertions(+), 325 deletions(-) create mode 100644 news/1 Enhancements/3581.md create mode 100644 src/client/common/liveshare/liveshare.ts create mode 100644 src/client/datascience/commandBroker.ts create mode 100644 src/client/datascience/historyMessageListener.ts create mode 100644 src/client/datascience/jupyter/jupyterExecutionFactory.ts create mode 100644 src/client/datascience/jupyter/jupyterServerFactory.ts create mode 100644 src/client/datascience/jupyter/liveshare/guestJupyterExecution.ts create mode 100644 src/client/datascience/jupyter/liveshare/guestJupyterServer.ts create mode 100644 src/client/datascience/jupyter/liveshare/hostJupyterExecution.ts create mode 100644 src/client/datascience/jupyter/liveshare/hostJupyterServer.ts create mode 100644 src/client/datascience/jupyter/liveshare/roleBasedFactory.ts create mode 100644 src/client/datascience/jupyter/liveshare/types.ts create mode 100644 src/client/datascience/jupyter/liveshare/utils.ts create mode 100644 src/client/datascience/liveshare/postOffice.ts create mode 100644 src/test/datascience/mockLiveShare.ts diff --git a/news/1 Enhancements/3581.md b/news/1 Enhancements/3581.md new file mode 100644 index 000000000000..67adb6e9e8b5 --- /dev/null +++ b/news/1 Enhancements/3581.md @@ -0,0 +1 @@ +Support live share in Python Interactive window diff --git a/package-lock.json b/package-lock.json index 8d402bab7fb2..29238e7cc92a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17593,6 +17593,11 @@ "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-1.0.1.tgz", "integrity": "sha1-Eahr7+rDxKo+wIYjZRo8gabQu8g=" }, + "vsls": { + "version": "0.3.967", + "resolved": "https://registry.npmjs.org/vsls/-/vsls-0.3.967.tgz", + "integrity": "sha512-FFaRZz4RBo/QmUHvQophkzMzrTrsV8g169jUPEaL7UWak3FdwGdGvm2DlSZIZl36MzLNb/43BPq6WDzoKDwR4g==" + }, "w3c-hr-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.1.tgz", diff --git a/package.json b/package.json index 8136475744d0..58f1a9ab81b6 100644 --- a/package.json +++ b/package.json @@ -1157,6 +1157,12 @@ "description": "Regular expression used to identify markdown cells. All comments after this expression are considered part of the markdown. \nDefaults to '^(#\\s*%%\\s*\\[markdown\\]|#\\s*\\)' if left blank", "scope": "resource" }, + "python.dataScience.allowLiveShare": { + "type": "boolean", + "default": false, + "description": "Allow the Python Interactive window to be shared during a Live Share session (experimental)", + "scope": "resource" + }, "python.disableInstallationCheck": { "type": "boolean", "default": false, @@ -2044,6 +2050,7 @@ "vscode-languageclient": "^4.4.0", "vscode-languageserver": "^4.4.0", "vscode-languageserver-protocol": "^3.10.3", + "vsls": "^0.3.967", "winreg": "^1.2.4", "xml2js": "^0.4.19" }, diff --git a/package.nls.json b/package.nls.json index d4ee0afabdcf..94789cc66cb9 100644 --- a/package.nls.json +++ b/package.nls.json @@ -137,6 +137,13 @@ "DataScience.notebookVersionFormat": "Jupyter Notebook Version: {0}", "DataScience.jupyterKernelNotSupportedOnActive": "Jupyter kernel cannot be started from '{0}'. Using closest match {1} instead.", "DataScience.jupyterKernelSpecNotFound": "Cannot create a Jupyter kernel spec and none are available for use", + "DataScience.liveShareConnectFailure" : "Cannot connect to host Jupyter session. URI not found.", + "DataScience.liveShareCannotSpawnNotebooks" : "Spawning Jupyter notebooks is not supported over a live share connection", + "DataScience.liveShareCannotImportNotebooks" : "Importing notebooks is not currently supported over a live share connection", + "DataScience.liveShareHostFormat" : "{0} Jupyter Server", + "DataScience.liveShareSyncFailure" : "Synchronization failure during live share startup.", + "DataScience.liveShareServiceFailure" : "Failure starting '{0}' service during live share connection.", + "DataScience.documentMismatch": "Cannot run cells, duplicate documents for {0} found.", "diagnostics.warnSourceMaps": "Source map support is enabled in the Python Extension, this will adversely impact performance of the extension.", "diagnostics.disableSourceMaps": "Disable Source Map Support", "diagnostics.warnBeforeEnablingSourceMaps": "Enabling source map support in the Python Extension will adversely impact performance of the extension.", diff --git a/src/client/common/application/types.ts b/src/client/common/application/types.ts index 339dda254c99..b766712a217c 100644 --- a/src/client/common/application/types.ts +++ b/src/client/common/application/types.ts @@ -45,6 +45,9 @@ import { WorkspaceFolderPickOptions, WorkspaceFoldersChangeEvent } from 'vscode'; +import * as vsls from 'vsls/vscode'; + +import { IAsyncDisposable } from '../types'; // tslint:disable:no-any unified-signatures @@ -808,7 +811,7 @@ export interface IApplicationEnvironment { } export const IWebPanelMessageListener = Symbol('IWebPanelMessageListener'); -export interface IWebPanelMessageListener extends Disposable { +export interface IWebPanelMessageListener extends IAsyncDisposable { /** * Listens to web panel messages * @param message: the message being sent @@ -862,3 +865,9 @@ export interface IWebPanelProvider { */ create(listener: IWebPanelMessageListener, title: string, mainScriptPath: string, embeddedCss?: string, settings?: any): IWebPanel; } + +// Wraps the vsls liveshare API +export const ILiveShareApi = Symbol('ILiveShareApi'); +export interface ILiveShareApi { + getApi(): Promise; +} diff --git a/src/client/common/liveshare/liveshare.ts b/src/client/common/liveshare/liveshare.ts new file mode 100644 index 000000000000..cd83a3e88f8c --- /dev/null +++ b/src/client/common/liveshare/liveshare.ts @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +'use strict'; +import { inject, injectable } from 'inversify'; +import * as vsls from 'vsls/vscode'; + +import { ILiveShareApi, IWorkspaceService } from '../application/types'; +import { IConfigurationService, IDisposableRegistry } from '../types'; + +// tslint:disable:no-any unified-signatures + +@injectable() +export class LiveShareApi implements ILiveShareApi { + + private supported : boolean = false; + private apiPromise : Promise | undefined; + + constructor( + @inject(IDisposableRegistry) disposableRegistry : IDisposableRegistry, + @inject(IWorkspaceService) workspace : IWorkspaceService, + @inject(IConfigurationService) private configService : IConfigurationService + ) { + const disposable = workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('python.dataScience', undefined)) { + // When config changes happen, recreate our commands. + this.onSettingsChanged(); + } + }); + disposableRegistry.push(disposable); + this.onSettingsChanged(); + } + + public getApi(): Promise { + return this.apiPromise!; + } + + private onSettingsChanged() { + const supported = this.configService.getSettings().datascience.allowLiveShare; + if (supported !== this.supported) { + this.supported = supported ? true : false; + this.apiPromise = supported ? vsls.getApi() : Promise.resolve(null); + } else if (!this.apiPromise) { + this.apiPromise = Promise.resolve(null); + } + } +} diff --git a/src/client/common/serviceRegistry.ts b/src/client/common/serviceRegistry.ts index 7852ed829f4d..fda52ea44310 100644 --- a/src/client/common/serviceRegistry.ts +++ b/src/client/common/serviceRegistry.ts @@ -15,6 +15,7 @@ import { ICommandManager, IDebugService, IDocumentManager, + ILiveShareApi, ITerminalManager, IWorkspaceService } from './application/types'; @@ -24,6 +25,7 @@ import { ConfigurationService } from './configuration/service'; import { EditorUtils } from './editor'; import { FeatureDeprecationManager } from './featureDeprecationManager'; import { ProductInstaller } from './installer/productInstaller'; +import { LiveShareApi } from './liveshare/liveshare'; import { Logger } from './logger'; import { BrowserService } from './net/browser'; import { HttpClient } from './net/httpClient'; @@ -93,6 +95,7 @@ export function registerTypes(serviceManager: IServiceManager) { serviceManager.addSingleton(INugetService, NugetService); serviceManager.addSingleton(ITerminalActivator, TerminalActivator); serviceManager.addSingleton(ITerminalActivationHandler, PowershellTerminalActivationFailedHandler); + serviceManager.addSingleton(ILiveShareApi, LiveShareApi); serviceManager.addSingleton(ITerminalHelper, TerminalHelper); serviceManager.addSingleton( diff --git a/src/client/common/types.ts b/src/client/common/types.ts index 137a47f8fc2b..73e6b423ede3 100644 --- a/src/client/common/types.ts +++ b/src/client/common/types.ts @@ -297,6 +297,7 @@ export interface IDataScienceSettings { sendSelectionToInteractiveWindow: boolean; markdownRegularExpression: string; codeRegularExpression: string; + allowLiveShare? : boolean; } export const IConfigurationService = Symbol('IConfigurationService'); diff --git a/src/client/common/utils/localize.ts b/src/client/common/utils/localize.ts index a5c88999f133..f41efd3479ed 100644 --- a/src/client/common/utils/localize.ts +++ b/src/client/common/utils/localize.ts @@ -125,6 +125,13 @@ export namespace DataScience { export const sysInfoURILabel = localize('DataScience.sysInfoURILabel', 'Jupyter Server URI: '); export const executingCodeFailure = localize('DataScience.executingCodeFailure', 'Executing code failed : {0}'); export const inputWatermark = localize('DataScience.inputWatermark', 'Shift-enter to run'); + export const liveShareConnectFailure = localize('DataScience.liveShareConnectFailure', 'Cannot connect to host jupyter session. URI not found.'); + export const liveShareCannotSpawnNotebooks = localize('DataScience.liveShareCannotSpawnNotebooks', 'Spawning jupyter notebooks is not supported over a live share connection'); + export const liveShareCannotImportNotebooks = localize('DataScience.liveShareCannotImportNotebooks', 'Importing notebooks is not currently supported over a live share connection'); + export const liveShareHostFormat = localize('DataScience.liveShareHostFormat', '{0} Jupyter Server'); + export const liveShareSyncFailure = localize('DataScience.liveShareSyncFailure', 'Synchronization failure during live share startup.'); + export const liveShareServiceFailure = localize('DataScience.liveShareServiceFailure', 'Failure starting \'{0}\' service during live share connection.'); + export const documentMismatch = localize('DataScience.documentMismatch', 'Cannot run cells, duplicate documents for {0} found.'); } export namespace DebugConfigurationPrompts { diff --git a/src/client/common/utils/misc.ts b/src/client/common/utils/misc.ts index 3b91f048ff43..5e53a879ffd5 100644 --- a/src/client/common/utils/misc.ts +++ b/src/client/common/utils/misc.ts @@ -1,7 +1,23 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. - 'use strict'; +import { IAsyncDisposable, IDisposable } from '../types'; // tslint:disable-next-line:no-empty export function noop() { } + +export function using(disposable: T, func: (obj: T) => void) { + try { + func(disposable); + } finally { + disposable.dispose(); + } +} + +export async function usingAsync(disposable: T, func: (obj: T) => Promise) : Promise { + try { + return await func(disposable); + } finally { + await disposable.dispose(); + } +} diff --git a/src/client/datascience/commandBroker.ts b/src/client/datascience/commandBroker.ts new file mode 100644 index 000000000000..1c9dddd777af --- /dev/null +++ b/src/client/datascience/commandBroker.ts @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +'use strict'; +import { inject, injectable } from 'inversify'; +import { Disposable, TextEditor, TextEditorEdit } from 'vscode'; +import * as vsls from 'vsls/vscode'; + +import { ICommandManager, ILiveShareApi } from '../common/application/types'; +import { LiveShare } from './constants'; +import { PostOffice } from './liveshare/postOffice'; +import { ICommandBroker } from './types'; + +// tslint:disable:no-any + +// This class acts as a broker between the VSCode command manager and a potential live share session +// It works like so: +// -- If not connected to any live share session, then just register commands as normal +// -- If a host, register commands as normal (as they will be listened to), but when they are hit, post them to all guests +// -- If a guest, register commands as normal (as they will be ignored), but also register for notifications from the host. +@injectable() +export class CommandBroker implements ICommandBroker { + + private postOffice : PostOffice; + constructor( + @inject(ILiveShareApi) liveShare: ILiveShareApi, + @inject(ICommandManager) private commandManager: ICommandManager) { + this.postOffice = new PostOffice(LiveShare.CommandBrokerService, liveShare); + } + + public registerCommand(command: string, callback: (...args: any[]) => void, thisArg?: any): Disposable { + // Modify the callback such that it sends the command to our service + const disposable = this.commandManager.registerCommand(command, (...args: any[]) => this.wrapCallback(command, callback, ...args), thisArg); + + // Register it for lookup + this.register(command, callback, thisArg).ignoreErrors(); + + return disposable; + } + public registerTextEditorCommand(command: string, callback: (textEditor: TextEditor, edit: TextEditorEdit, ...args: any[]) => void, thisArg?: any): Disposable { + // Modify the callback such that it sends the command to our service + const disposable = this.commandManager.registerCommand( + command, + (textEditor: TextEditor, edit: TextEditorEdit, ...args: any[]) => this.wrapTextEditorCallback(command, callback, textEditor, edit, ...args), thisArg); + + // Register it for lookup + this.register(command, callback, thisArg).ignoreErrors(); + + return disposable; + } + public executeCommand(command: string, ...rest: any[]): Thenable { + // Execute the command but potentially also send to our service too + this.postCommand(command, ...rest).ignoreErrors(); + return this.commandManager.executeCommand(command, ...rest); + } + public getCommands(filterInternal?: boolean): Thenable { + // This does not go across to the other side. Just return the command registered locally + return this.commandManager.getCommands(filterInternal); + } + + private async register(command: string, callback: (...args: any[]) => void, thisArg?: any) : Promise { + return this.postOffice.registerCallback(command, callback, thisArg); + } + + private wrapCallback(command: string, callback: (...args: any[]) => void, ...args: any[]) { + // Have the post office handle it. + this.postCommand(command, ...args).ignoreErrors(); + } + + private wrapTextEditorCallback(command: string, callback: (textEditor: TextEditor, edit: TextEditorEdit, ...args: any[]) => void, ...args: any[]) { + // Not really supported at the moment as we don't have a special case for the textEditor. But not using it. + this.postCommand(command, ...args).ignoreErrors(); + } + + private async postCommand(command: string, ...rest: any[]): Promise { + // Make sure we're the host (or none). Guest shouldn't be sending + if (this.postOffice.role() !== vsls.Role.Guest) { + // This means we should send this across to the other side. + return this.postOffice.postCommand(command, ...rest); + } + } +} diff --git a/src/client/datascience/constants.ts b/src/client/datascience/constants.ts index 978ed109e758..6fe112436afe 100644 --- a/src/client/datascience/constants.ts +++ b/src/client/datascience/constants.ts @@ -3,6 +3,8 @@ 'use strict'; +import { IS_WINDOWS } from '../common/platform/constants'; + export namespace Commands { export const RunAllCells = 'python.datascience.runallcells'; export const RunCell = 'python.datascience.runcell'; @@ -36,6 +38,15 @@ export namespace EditorContexts { export namespace RegExpValues { export const PythonCellMarker = /^(#\s*%%|#\s*\|#\s*In\[\d*?\]|#\s*In\[ \])/; export const PythonMarkdownCellMarker = /^(#\s*%%\s*\[markdown\]|#\s*\)/; + export const CheckJupyterRegEx = IS_WINDOWS ? /^jupyter?\.exe$/ : /^jupyter?$/; + export const PyKernelOutputRegEx = /.*\s+(.+)$/m; + export const KernelSpecOutputRegEx = /^\s*(\S+)\s+(\S+)$/; + export const UrlPatternRegEx = /(https?:\/\/[^\s]+)/ ; + export const HttpPattern = /https?:\/\//; + export const ExtractPortRegex = /https?:\/\/[^\s]+:(\d+)[^\s]+/; + export const ConvertToRemoteUri = /(https?:\/\/)([^\s])+(:\d+[^\s]*)/; + export const ParamsExractorRegEx = /\S+\((.*)\)\s*{/; + export const ArgsSplitterRegEx = /([^\s,]+)/g; } export namespace HistoryMessages { @@ -107,3 +118,37 @@ export namespace Identifiers { export const EmptyFileName = '2DB9B899-6519-4E1B-88B0-FA728A274115'; export const GeneratedThemeName = 'ipython-theme'; // This needs to be all lower class and a valid class name. } + +export namespace JupyterCommands { + export const NotebookCommand = 'notebook'; + export const ConvertCommand = 'nbconvert'; + export const KernelSpecCommand = 'kernelspec'; + export const KernelCreateCommand = 'ipykernel'; + +} + +export namespace LiveShare { + export const None = 'none'; + export const Host = 'host'; + export const Guest = 'guest'; + export const JupyterExecutionService = 'jupyterExecutionService'; + export const JupyterServerSharedService = 'jupyterServerSharedService'; + export const CommandBrokerService = 'commmandBrokerService'; + export const WebPanelMessageService = 'webPanelMessageService'; + export const LiveShareBroadcastRequest = 'broadcastRequest'; + export const ResponseLifetime = 15000; + export const ResponseRange = 1000; // Range of time alloted to check if a response matches or not +} + +export namespace LiveShareCommands { + export const isNotebookSupported = 'isNotebookSupported'; + export const isImportSupported = 'isImportSupported'; + export const isKernelCreateSupported = 'isKernelCreateSupported'; + export const isKernelSpecSupported = 'isKernelSpecSupported'; + export const connectToNotebookServer = 'connectToNotebookServer'; + export const getUsableJupyterPython = 'getUsableJupyterPython'; + export const getSysInfo = 'getSysInfo'; + export const serverResponse = 'serverResponse'; + export const catchupRequest = 'catchupRequest'; + export const syncRequest = 'synchRequest'; +} diff --git a/src/client/datascience/datascience.ts b/src/client/datascience/datascience.ts index 0cc473d58ee8..643255c83c27 100644 --- a/src/client/datascience/datascience.ts +++ b/src/client/datascience/datascience.ts @@ -7,7 +7,7 @@ import { inject, injectable } from 'inversify'; import { URL } from 'url'; import * as vscode from 'vscode'; -import { IApplicationShell, ICommandManager, IDocumentManager } from '../common/application/types'; +import { IApplicationShell, IDocumentManager } from '../common/application/types'; import { PYTHON_ALLFILES, PYTHON_LANGUAGE } from '../common/constants'; import { ContextKey } from '../common/contextKey'; import { @@ -23,7 +23,13 @@ import { IServiceContainer } from '../ioc/types'; import { captureTelemetry } from '../telemetry'; import { hasCells } from './cellFactory'; import { Commands, EditorContexts, Settings, Telemetry } from './constants'; -import { ICodeWatcher, IDataScience, IDataScienceCodeLensProvider, IDataScienceCommandListener } from './types'; +import { + ICodeWatcher, + ICommandBroker, + IDataScience, + IDataScienceCodeLensProvider, + IDataScienceCommandListener +} from './types'; @injectable() export class DataScience implements IDataScience { @@ -31,8 +37,9 @@ export class DataScience implements IDataScience { private readonly commandListeners: IDataScienceCommandListener[]; private readonly dataScienceSurveyBanner: IPythonExtensionBanner; private changeHandler: IDisposable | undefined; + private startTime: number = Date.now(); constructor(@inject(IServiceContainer) private serviceContainer: IServiceContainer, - @inject(ICommandManager) private commandManager: ICommandManager, + @inject(ICommandBroker) private commandBroker: ICommandBroker, @inject(IDisposableRegistry) private disposableRegistry: IDisposableRegistry, @inject(IExtensionContext) private extensionContext: IExtensionContext, @inject(IDataScienceCodeLensProvider) private dataScienceCodeLensProvider: IDataScienceCodeLensProvider, @@ -43,6 +50,10 @@ export class DataScience implements IDataScience { this.dataScienceSurveyBanner = this.serviceContainer.get(IPythonExtensionBanner, BANNER_NAME_DS_SURVEY); } + public get activationStartTime() : number { + return this.startTime; + } + public async activate(): Promise { this.registerCommands(); @@ -69,25 +80,27 @@ export class DataScience implements IDataScience { } } - public async runAllCells(codeWatcher: ICodeWatcher): Promise { + public async runAllCells(file: string): Promise { this.dataScienceSurveyBanner.showBanner().ignoreErrors(); - let activeCodeWatcher: ICodeWatcher | undefined = codeWatcher; - if (!activeCodeWatcher) { - activeCodeWatcher = this.getCurrentCodeWatcher(); + let codeWatcher = this.getCodeWatcher(file); + if (!codeWatcher) { + codeWatcher = this.getCurrentCodeWatcher(); } - if (activeCodeWatcher) { - return activeCodeWatcher.runAllCells(); + if (codeWatcher) { + return codeWatcher.runAllCells(); } else { return Promise.resolve(); } } - public async runCell(codeWatcher: ICodeWatcher, range: vscode.Range): Promise { + // Note: see codewatcher.ts where the runcell command args are attached. The reason we don't have any + // objects for parameters is because they can't be recreated when passing them through the LiveShare API + public async runCell(file: string, startLine: number, startChar: number, endLine: number, endChar: number): Promise { this.dataScienceSurveyBanner.showBanner().ignoreErrors(); - + const codeWatcher = this.getCodeWatcher(file); if (codeWatcher) { - return codeWatcher.runCell(range); + return codeWatcher.runCell(new vscode.Range(startLine, startChar, endLine, endChar)); } else { return this.runCurrentCell(); } @@ -176,13 +189,24 @@ export class DataScience implements IDataScience { private onSettingsChanged = () => { const settings = this.configuration.getSettings(); const enabled = settings.datascience.enabled; - let editorContext = new ContextKey(EditorContexts.DataScienceEnabled, this.commandManager); + let editorContext = new ContextKey(EditorContexts.DataScienceEnabled, this.commandBroker); editorContext.set(enabled).catch(); const ownsSelection = settings.datascience.sendSelectionToInteractiveWindow; - editorContext = new ContextKey(EditorContexts.OwnsSelection, this.commandManager); + editorContext = new ContextKey(EditorContexts.OwnsSelection, this.commandBroker); editorContext.set(ownsSelection && enabled).catch(); } + private getCodeWatcher(file: string): ICodeWatcher | undefined { + const possibleDocuments = this.documentManager.textDocuments.filter(d => d.fileName === file); + if (possibleDocuments && possibleDocuments.length === 1) { + return this.dataScienceCodeLensProvider.getCodeWatcher(possibleDocuments[0]); + } else if (possibleDocuments && possibleDocuments.length > 1) { + throw new Error(localize.DataScience.documentMismatch().format(file)); + } + + return undefined; + } + // Get our matching code watcher for the active document private getCurrentCodeWatcher(): ICodeWatcher | undefined { const activeEditor = this.documentManager.activeTextEditor; @@ -195,26 +219,26 @@ export class DataScience implements IDataScience { } private registerCommands(): void { - let disposable = this.commandManager.registerCommand(Commands.RunAllCells, this.runAllCells, this); + let disposable = this.commandBroker.registerCommand(Commands.RunAllCells, this.runAllCells, this); this.disposableRegistry.push(disposable); - disposable = this.commandManager.registerCommand(Commands.RunCell, this.runCell, this); + disposable = this.commandBroker.registerCommand(Commands.RunCell, this.runCell, this); this.disposableRegistry.push(disposable); - disposable = this.commandManager.registerCommand(Commands.RunCurrentCell, this.runCurrentCell, this); + disposable = this.commandBroker.registerCommand(Commands.RunCurrentCell, this.runCurrentCell, this); this.disposableRegistry.push(disposable); - disposable = this.commandManager.registerCommand(Commands.RunCurrentCellAdvance, this.runCurrentCellAndAdvance, this); + disposable = this.commandBroker.registerCommand(Commands.RunCurrentCellAdvance, this.runCurrentCellAndAdvance, this); this.disposableRegistry.push(disposable); - disposable = this.commandManager.registerCommand(Commands.ExecSelectionInInteractiveWindow, this.runSelectionOrLine, this); + disposable = this.commandBroker.registerCommand(Commands.ExecSelectionInInteractiveWindow, this.runSelectionOrLine, this); this.disposableRegistry.push(disposable); - disposable = this.commandManager.registerCommand(Commands.SelectJupyterURI, this.selectJupyterURI, this); + disposable = this.commandBroker.registerCommand(Commands.SelectJupyterURI, this.selectJupyterURI, this); this.disposableRegistry.push(disposable); this.commandListeners.forEach((listener: IDataScienceCommandListener) => { - listener.register(this.commandManager); + listener.register(this.commandBroker); }); } private onChangedActiveTextEditor() { // Setup the editor context for the cells - const editorContext = new ContextKey(EditorContexts.HasCodeCells, this.commandManager); + const editorContext = new ContextKey(EditorContexts.HasCodeCells, this.commandBroker); const activeEditor = this.documentManager.activeTextEditor; if (activeEditor && activeEditor.document.languageId === PYTHON_LANGUAGE) { diff --git a/src/client/datascience/editor-integration/codelensprovider.ts b/src/client/datascience/editor-integration/codelensprovider.ts index 4d761c1caef7..1e84802a723b 100644 --- a/src/client/datascience/editor-integration/codelensprovider.ts +++ b/src/client/datascience/editor-integration/codelensprovider.ts @@ -1,10 +1,10 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. - 'use strict'; - import { inject, injectable } from 'inversify'; import * as vscode from 'vscode'; + +import { IDocumentManager } from '../../common/application/types'; import { IConfigurationService, IDataScienceSettings } from '../../common/types'; import { IServiceContainer } from '../../ioc/types'; import { ICodeWatcher, IDataScienceCodeLensProvider } from '../types'; @@ -13,6 +13,7 @@ import { ICodeWatcher, IDataScienceCodeLensProvider } from '../types'; export class DataScienceCodeLensProvider implements IDataScienceCodeLensProvider { private activeCodeWatchers: ICodeWatcher[] = []; constructor(@inject(IServiceContainer) private serviceContainer: IServiceContainer, + @inject(IDocumentManager) private documentManager: IDocumentManager, @inject(IConfigurationService) private configuration: IConfigurationService) { } @@ -66,6 +67,16 @@ export class DataScienceCodeLensProvider implements IDataScienceCodeLensProvider // If we have an old version remove it from the active list this.activeCodeWatchers.splice(index, 1); } + + // Create a new watcher for this file if we can find a matching document + const possibleDocuments = this.documentManager.textDocuments.filter(d => d.fileName === fileName); + if (possibleDocuments && possibleDocuments.length > 0) { + const newCodeWatcher = this.serviceContainer.get(ICodeWatcher); + newCodeWatcher.setDocument(possibleDocuments[0]); + this.activeCodeWatchers.push(newCodeWatcher); + return newCodeWatcher; + } + return undefined; } } diff --git a/src/client/datascience/editor-integration/codewatcher.ts b/src/client/datascience/editor-integration/codewatcher.ts index 7d8fe2d1bb22..6e4cb1b90ced 100644 --- a/src/client/datascience/editor-integration/codewatcher.ts +++ b/src/client/datascience/editor-integration/codewatcher.ts @@ -41,15 +41,17 @@ export class CodeWatcher implements ICodeWatcher { const cells = generateCellRanges(document, this.cachedSettings); this.codeLenses = []; + // Be careful here. These arguments will be serialized during liveshare sessions + // and so shouldn't reference local objects. cells.forEach(cell => { const cmd: Command = { - arguments: [this, cell.range], + arguments: [document.fileName, cell.range.start.line, cell.range.start.character, cell.range.end.line, cell.range.end.character], title: localize.DataScience.runCellLensCommandTitle(), command: Commands.RunCell }; this.codeLenses.push(new CodeLens(cell.range, cmd)); const runAllCmd: Command = { - arguments: [this], + arguments: [document.fileName], title: localize.DataScience.runAllCellsLensCommandTitle(), command: Commands.RunAllCells }; @@ -81,8 +83,8 @@ export class CodeWatcher implements ICodeWatcher { // run them one by one for (const lens of this.codeLenses) { // Make sure that we have the correct command (RunCell) lenses - if (lens.command && lens.command.command === Commands.RunCell && lens.command.arguments && lens.command.arguments.length >= 2) { - const range: Range = lens.command.arguments[1]; + if (lens.command && lens.command.command === Commands.RunCell && lens.command.arguments && lens.command.arguments.length >= 5) { + const range: Range = new Range(lens.command.arguments[1], lens.command.arguments[2], lens.command.arguments[3], lens.command.arguments[4]); if (this.document && range) { const code = this.document.getText(range); await activeHistory.addCode(code, this.getFileName(), range.start.line); @@ -126,6 +128,7 @@ export class CodeWatcher implements ICodeWatcher { public async runCell(range: Range) { const activeHistory = this.historyProvider.getOrCreateActive(); if (this.document) { + // Use that to get our code. const code = this.document.getText(range); try { diff --git a/src/client/datascience/history.ts b/src/client/datascience/history.ts index 681861433c2e..9fee53101880 100644 --- a/src/client/datascience/history.ts +++ b/src/client/datascience/history.ts @@ -3,11 +3,9 @@ 'use strict'; import '../common/extensions'; -import { nbformat } from '@jupyterlab/coreutils'; import * as fs from 'fs-extra'; import { inject, injectable } from 'inversify'; import * as path from 'path'; -import * as uuid from 'uuid/v4'; import { Event, EventEmitter, Position, Range, Selection, TextEditor, Uri, ViewColumn } from 'vscode'; import { Disposable } from 'vscode-jsonrpc'; @@ -15,8 +13,8 @@ import { IApplicationShell, ICommandManager, IDocumentManager, + ILiveShareApi, IWebPanel, - IWebPanelMessageListener, IWebPanelProvider, IWorkspaceService } from '../common/application/types'; @@ -30,6 +28,7 @@ import * as localize from '../common/utils/localize'; import { IInterpreterService } from '../interpreter/contracts'; import { captureTelemetry, sendTelemetryEvent } from '../telemetry'; import { EditorContexts, HistoryMessages, Identifiers, Settings, Telemetry } from './constants'; +import { HistoryMessageListener } from './historyMessageListener'; import { JupyterInstallError } from './jupyter/jupyterInstallError'; import { CellState, @@ -53,7 +52,7 @@ export enum SysInfoReason { } @injectable() -export class History implements IWebPanelMessageListener, IHistory { +export class History implements IHistory { private disposed: boolean = false; private webPanel: IWebPanel | undefined; private loadPromise: Promise; @@ -63,12 +62,13 @@ export class History implements IWebPanelMessageListener, IHistory { private restartingKernel: boolean = false; private potentiallyUnfinishedStatus: Disposable[] = []; private addedSysInfo: boolean = false; - private ignoreCount: number = 0; private waitingForExportCells: boolean = false; private jupyterServer: INotebookServer | undefined; private changeHandler: IDisposable | undefined; + private messageListener : HistoryMessageListener; constructor( + @inject(ILiveShareApi) liveShare : ILiveShareApi, @inject(IApplicationShell) private applicationShell: IApplicationShell, @inject(IDocumentManager) private documentManager: IDocumentManager, @inject(IInterpreterService) private interpreterService: IInterpreterService, @@ -92,6 +92,9 @@ export class History implements IWebPanelMessageListener, IHistory { this.closedEvent = new EventEmitter(); this.disposables.push(this.closedEvent); + // Create a history message listener to listen to messages from our webpanel (or remote session) + this.messageListener = new HistoryMessageListener(liveShare, this.onMessage, this.dispose); + // Load on a background thread. this.loadPromise = this.load(); } @@ -187,13 +190,17 @@ export class History implements IWebPanelMessageListener, IHistory { } } - public async dispose() { + public dispose = async () => { if (!this.disposed) { this.disposed = true; - this.interpreterChangedDisposable.dispose(); - this.closedEvent.fire(this); + if (this.interpreterChangedDisposable) { + this.interpreterChangedDisposable.dispose(); + } + if (this.closedEvent) { + this.closedEvent.fire(this); + } if (this.jupyterServer) { - await this.jupyterServer.shutdown(); + await this.jupyterServer.dispose(); } this.updateContexts(); } @@ -379,6 +386,12 @@ export class History implements IWebPanelMessageListener, IHistory { const statusLoad = this.setStatus(localize.DataScience.startingJupyter()); try { await this.loadPromise; + } catch (exc) { + // We should dispose ourselvs if the load fails. Othewise the user + // updates their install and we just fail again because the load promise is the same. + await this.dispose(); + + throw exc; } finally { statusLoad.dispose(); } @@ -439,14 +452,8 @@ export class History implements IWebPanelMessageListener, IHistory { } private sendCell(cell: ICell, message: string) { - // Remove our ignore count from the execution count prior to sending - const copy = JSON.parse(JSON.stringify(cell)); - if (copy.data && copy.data.execution_count !== null && copy.data.execution_count > 0) { - const count = cell.data.execution_count as number; - copy.data.execution_count = count - this.ignoreCount; - } if (this.webPanel) { - this.webPanel.postMessage({ type: message, payload: copy }); + this.webPanel.postMessage({ type: message, payload: cell }); } } @@ -666,71 +673,27 @@ export class History implements IWebPanelMessageListener, IHistory { return workingDir; } - private extractStreamOutput(cell: ICell): string { - let result = ''; - if (cell.state === CellState.error || cell.state === CellState.finished) { - const outputs = cell.data.outputs as nbformat.IOutput[]; - if (outputs) { - outputs.forEach(o => { - if (o.output_type === 'stream') { - const stream = o as nbformat.IStream; - result = result.concat(stream.text.toString()); - } else { - const data = o.data; - if (data && data.hasOwnProperty('text/plain')) { - // tslint:disable-next-line:no-any - result = result.concat((data as any)['text/plain']); - } - } - }); - } - } - return result; - } - private generateSysInfoCell = async (reason: SysInfoReason): Promise => { // Execute the code 'import sys\r\nsys.version' and 'import sys\r\nsys.executable' to get our // version and executable if (this.jupyterServer) { const message = await this.generateSysInfoMessage(reason); - // tslint:disable-next-line:no-multiline-string - const versionCells = await this.jupyterServer.execute(`import sys\r\nsys.version`, 'foo.py', 0); - // tslint:disable-next-line:no-multiline-string - const pathCells = await this.jupyterServer.execute(`import sys\r\nsys.executable`, 'foo.py', 0); - // tslint:disable-next-line:no-multiline-string - const notebookVersionCells = await this.jupyterServer.execute(`import notebook\r\nnotebook.version_info`, 'foo.py', 0); - - // Both should have streamed output - const version = versionCells.length > 0 ? this.extractStreamOutput(versionCells[0]).trimQuotes() : ''; - const notebookVersion = notebookVersionCells.length > 0 ? this.extractStreamOutput(notebookVersionCells[0]).trimQuotes() : ''; - const pythonPath = versionCells.length > 0 ? this.extractStreamOutput(pathCells[0]).trimQuotes() : ''; - - // Both should influence our ignore count. We don't want them to count against execution - this.ignoreCount = this.ignoreCount + 3; - - // Connection string only for our initial start, not restart or interrupt - let connectionString: string = ''; - if (reason === SysInfoReason.Start) { - connectionString = this.generateConnectionInfoString(this.jupyterServer.getConnectionInfo()); - } - // Combine this data together to make our sys info - return { - data: { - cell_type: 'sys_info', - message: message, - version: version, - notebook_version: localize.DataScience.notebookVersionFormat().format(notebookVersion), - path: pythonPath, - connection: connectionString, - metadata: {}, - source: [] - }, - id: uuid(), - file: '', - line: 0, - state: CellState.finished - }; + // The server handles getting this data. + const sysInfo = await this.jupyterServer.getSysInfo(); + if (sysInfo) { + // Connection string only for our initial start, not restart or interrupt + let connectionString: string = ''; + if (reason === SysInfoReason.Start) { + connectionString = this.generateConnectionInfoString(this.jupyterServer.getConnectionInfo()); + } + + // Update our sys info with our locally applied data. + sysInfo.data.message = message; + sysInfo.data.connection = connectionString; + + return sysInfo; + } } } @@ -770,7 +733,6 @@ export class History implements IWebPanelMessageListener, IHistory { private addSysInfo = async (reason: SysInfoReason): Promise => { if (!this.addedSysInfo || reason === SysInfoReason.Interrupt || reason === SysInfoReason.Restart) { this.addedSysInfo = true; - this.ignoreCount = 0; // Generate a new sys info cell and send it to the web panel. const sysInfo = await this.generateSysInfoCell(reason); @@ -805,7 +767,7 @@ export class History implements IWebPanelMessageListener, IHistory { // Use this script to create our web view panel. It should contain all of the necessary // script to communicate with this class. - this.webPanel = this.provider.create(this, localize.DataScience.historyTitle(), mainScriptPath, css, settings); + this.webPanel = this.provider.create(this.messageListener, localize.DataScience.historyTitle(), mainScriptPath, css, settings); } } @@ -833,8 +795,12 @@ export class History implements IWebPanelMessageListener, IHistory { } } - // Otherwise we continue loading - await Promise.all([this.loadJupyterServer(), this.loadWebPanel()]); + // Get the web panel to show first + await this.loadWebPanel(); + + // Then load the jupyter server + return this.loadJupyterServer(); + } finally { status.dispose(); } diff --git a/src/client/datascience/historyMessageListener.ts b/src/client/datascience/historyMessageListener.ts new file mode 100644 index 000000000000..0332f5c95c9f --- /dev/null +++ b/src/client/datascience/historyMessageListener.ts @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +'use strict'; +import '../common/extensions'; + +import { ILiveShareApi, IWebPanelMessageListener } from '../common/application/types'; +import { HistoryMessages, LiveShare } from './constants'; +import { PostOffice } from './liveshare/postOffice'; + +// tslint:disable:no-any + +// This class listens to messages that come from the local Python Interactive window +export class HistoryMessageListener implements IWebPanelMessageListener { + private postOffice : PostOffice; + private disposedCallback : () => void; + private callback : (message: string, payload: any) => void; + + constructor(liveShare: ILiveShareApi, callback: (message: string, payload: any) => void, disposed: () => void) { + this.postOffice = new PostOffice(LiveShare.WebPanelMessageService, liveShare); + + // Save our dispose callback so we remove our history window + this.disposedCallback = disposed; + + // Save our local callback so we can handle the non broadcast case(s) + this.callback = callback; + + // We need to register callbacks for all history messages. Well except for send info + Object.keys(HistoryMessages).forEach(k => { + if (k !== HistoryMessages.SendInfo) { + this.postOffice.registerCallback(HistoryMessages[k], (a : any) => callback(HistoryMessages[k], a)).ignoreErrors(); + } + }); + } + + public async dispose() { + await this.postOffice.dispose(); + this.disposedCallback(); + } + + public onMessage(message: string, payload: any) { + // We received a message from the local webview. Broadcast it to everybody unless it's a sendinfo + if (message !== HistoryMessages.SendInfo) { + this.postOffice.postCommand(message, payload).ignoreErrors(); + } else { + // Send to just our local callback. + this.callback(message, payload); + } + } +} diff --git a/src/client/datascience/historycommandlistener.ts b/src/client/datascience/historycommandlistener.ts index 7316d2b83fe6..c8a05ecd34c8 100644 --- a/src/client/datascience/historycommandlistener.ts +++ b/src/client/datascience/historycommandlistener.ts @@ -7,7 +7,7 @@ import { inject, injectable } from 'inversify'; import { Position, Range, TextDocument, Uri, ViewColumn } from 'vscode'; import { CancellationToken, CancellationTokenSource } from 'vscode-jsonrpc'; -import { IApplicationShell, ICommandManager, IDocumentManager } from '../common/application/types'; +import { IApplicationShell, IDocumentManager } from '../common/application/types'; import { CancellationError } from '../common/cancellation'; import { PYTHON_LANGUAGE } from '../common/constants'; import { IFileSystem } from '../common/platform/types'; @@ -18,6 +18,7 @@ import { CommandSource } from '../unittests/common/constants'; import { generateCellRanges, generateCellsFromDocument } from './cellFactory'; import { Commands, Telemetry } from './constants'; import { + ICommandBroker, IDataScienceCommandListener, IHistoryProvider, IJupyterExecution, @@ -47,7 +48,7 @@ export class HistoryCommandListener implements IDataScienceCommandListener { this.disposableRegistry.push(disposable); } - public register(commandManager: ICommandManager): void { + public register(commandManager: ICommandBroker): void { let disposable = commandManager.registerCommand(Commands.ShowHistoryPane, () => this.showHistoryPane()); this.disposableRegistry.push(disposable); disposable = commandManager.registerCommand(Commands.ImportNotebook, async (file: Uri, cmdSource: CommandSource = CommandSource.commandPalette) => { @@ -234,7 +235,7 @@ export class HistoryCommandListener implements IDataScienceCommandListener { } finally { if (server) { - server.dispose(); + await server.dispose(); } } } diff --git a/src/client/datascience/jupyter/jupyterConnection.ts b/src/client/datascience/jupyter/jupyterConnection.ts index 661eb0d45937..5378f4c24d64 100644 --- a/src/client/datascience/jupyter/jupyterConnection.ts +++ b/src/client/datascience/jupyter/jupyterConnection.ts @@ -11,12 +11,10 @@ import { IConfigurationService, ILogger } from '../../common/types'; import { createDeferred, Deferred } from '../../common/utils/async'; import * as localize from '../../common/utils/localize'; import { IServiceContainer } from '../../ioc/types'; +import { RegExpValues } from '../constants'; import { IConnection } from '../types'; import { JupyterConnectError } from './jupyterConnectError'; -const UrlPatternRegEx = /(https?:\/\/[^\s]+)/ ; -const HttpPattern = /https?:\/\//; - export type JupyterServerInfo = { base_url: string; notebook_dir: string; @@ -119,7 +117,7 @@ class JupyterConnectionWaiter { // tslint:disable-next-line:no-any private getJupyterURLFromString(data: any) { - const urlMatch = UrlPatternRegEx.exec(data); + const urlMatch = RegExpValues.UrlPatternRegEx.exec(data); if (urlMatch && !this.startPromise.completed) { // URL is not being found for some reason. Pull it in forcefully // tslint:disable-next-line:no-require-imports @@ -142,7 +140,7 @@ class JupyterConnectionWaiter { private extractConnectionInformation = (data: any) => { this.output(data); - const httpMatch = HttpPattern.exec(data); + const httpMatch = RegExpValues.HttpPattern.exec(data); if (httpMatch && this.notebook_dir && this.startPromise && !this.startPromise.completed && this.getServerInfo) { // .then so that we can keep from pushing aync up to the subscribed observable function diff --git a/src/client/datascience/jupyter/jupyterExecution.ts b/src/client/datascience/jupyter/jupyterExecution.ts index 35469bb8b794..93496e7d77f1 100644 --- a/src/client/datascience/jupyter/jupyterExecution.ts +++ b/src/client/datascience/jupyter/jupyterExecution.ts @@ -3,16 +3,14 @@ 'use strict'; import { Kernel } from '@jupyterlab/services'; import * as fs from 'fs-extra'; -import { inject, injectable } from 'inversify'; import * as os from 'os'; import * as path from 'path'; import { URL } from 'url'; import * as uuid from 'uuid/v4'; -import { CancellationToken, Disposable } from 'vscode-jsonrpc'; +import { CancellationToken } from 'vscode-jsonrpc'; -import { IWorkspaceService } from '../../common/application/types'; +import { ILiveShareApi, IWorkspaceService } from '../../common/application/types'; import { Cancellation, CancellationError } from '../../common/cancellation'; -import { IS_WINDOWS } from '../../common/platform/constants'; import { IFileSystem, TemporaryDirectory } from '../../common/platform/types'; import { IProcessService, IProcessServiceFactory, IPythonExecutionFactory, SpawnOptions } from '../../common/process/types'; import { IAsyncDisposableRegistry, IConfigurationService, IDisposableRegistry, ILogger } from '../../common/types'; @@ -22,7 +20,7 @@ import { EXTENSION_ROOT_DIR } from '../../constants'; import { IInterpreterService, IKnownSearchPathsForInterpreters, PythonInterpreter } from '../../interpreter/contracts'; import { IServiceContainer } from '../../ioc/types'; import { captureTelemetry, sendTelemetryEvent } from '../../telemetry'; -import { Telemetry } from '../constants'; +import { JupyterCommands, RegExpValues, Telemetry } from '../constants'; import { IConnection, IJupyterCommand, @@ -35,35 +33,27 @@ import { import { JupyterConnection, JupyterServerInfo } from './jupyterConnection'; import { JupyterKernelSpec } from './jupyterKernelSpec'; -const CheckJupyterRegEx = IS_WINDOWS ? /^jupyter?\.exe$/ : /^jupyter?$/; -const NotebookCommand = 'notebook'; -const ConvertCommand = 'nbconvert'; -const KernelSpecCommand = 'kernelspec'; -const KernelCreateCommand = 'ipykernel'; -const PyKernelOutputRegEx = /.*\s+(.+)$/m; -const KernelSpecOutputRegEx = /^\s*(\S+)\s+(\S+)$/; - -@injectable() -export class JupyterExecution implements IJupyterExecution, Disposable { +export class JupyterExecutionBase implements IJupyterExecution { private processServicePromise: Promise; private commands: Record = {}; private jupyterPath: string | undefined; private usablePythonInterpreter: PythonInterpreter | undefined; - constructor(@inject(IPythonExecutionFactory) private executionFactory: IPythonExecutionFactory, - @inject(IInterpreterService) private interpreterService: IInterpreterService, - @inject(IProcessServiceFactory) private processServiceFactory: IProcessServiceFactory, - @inject(IKnownSearchPathsForInterpreters) private knownSearchPaths: IKnownSearchPathsForInterpreters, - @inject(ILogger) private logger: ILogger, - @inject(IDisposableRegistry) private disposableRegistry: IDisposableRegistry, - @inject(IAsyncDisposableRegistry) private asyncRegistry: IAsyncDisposableRegistry, - @inject(IFileSystem) private fileSystem: IFileSystem, - @inject(IJupyterSessionManager) private sessionManager: IJupyterSessionManager, - @inject(IWorkspaceService) workspace: IWorkspaceService, - @inject(IConfigurationService) private configuration: IConfigurationService, - @inject(IJupyterCommandFactory) private commandFactory : IJupyterCommandFactory, - @inject(IServiceContainer) private serviceContainer: IServiceContainer) { + constructor(liveShare: ILiveShareApi, + private executionFactory: IPythonExecutionFactory, + private interpreterService: IInterpreterService, + private processServiceFactory: IProcessServiceFactory, + private knownSearchPaths: IKnownSearchPathsForInterpreters, + private logger: ILogger, + private disposableRegistry: IDisposableRegistry, + private asyncRegistry: IAsyncDisposableRegistry, + private fileSystem: IFileSystem, + private sessionManager: IJupyterSessionManager, + workspace: IWorkspaceService, + private configuration: IConfigurationService, + private commandFactory : IJupyterCommandFactory, + private serviceContainer: IServiceContainer) { this.processServicePromise = this.processServiceFactory.create(); this.disposableRegistry.push(this.interpreterService.onDidChangeInterpreter(() => this.onSettingsChanged())); this.disposableRegistry.push(this); @@ -72,22 +62,22 @@ export class JupyterExecution implements IJupyterExecution, Disposable { const disposable = workspace.onDidChangeConfiguration(e => { if (e.affectsConfiguration('python.dataScience', undefined)) { // When config changes happen, recreate our commands. - this.dispose(); + this.onSettingsChanged(); } }); this.disposableRegistry.push(disposable); } } - public dispose() { + public dispose() : Promise { // Clear our usableJupyterInterpreter - this.usablePythonInterpreter = undefined; - this.commands = {}; + this.onSettingsChanged(); + return Promise.resolve(); } public isNotebookSupported(cancelToken?: CancellationToken): Promise { // See if we can find the command notebook - return Cancellation.race(() => this.isCommandSupported(NotebookCommand, cancelToken), cancelToken); + return Cancellation.race(() => this.isCommandSupported(JupyterCommands.NotebookCommand, cancelToken), cancelToken); } public async getUsableJupyterPython(cancelToken?: CancellationToken): Promise { @@ -98,19 +88,19 @@ export class JupyterExecution implements IJupyterExecution, Disposable { return this.usablePythonInterpreter; } - public isImportSupported = async (cancelToken?: CancellationToken): Promise => { + public isImportSupported(cancelToken?: CancellationToken): Promise { // See if we can find the command nbconvert - return Cancellation.race(() => this.isCommandSupported(ConvertCommand), cancelToken); + return Cancellation.race(() => this.isCommandSupported(JupyterCommands.ConvertCommand), cancelToken); } - public isKernelCreateSupported = async (cancelToken?: CancellationToken): Promise => { + public isKernelCreateSupported(cancelToken?: CancellationToken): Promise { // See if we can find the command ipykernel - return Cancellation.race(() => this.isCommandSupported(KernelCreateCommand), cancelToken); + return Cancellation.race(() => this.isCommandSupported(JupyterCommands.KernelCreateCommand), cancelToken); } - public isKernelSpecSupported = async (cancelToken?: CancellationToken): Promise => { + public isKernelSpecSupported(cancelToken?: CancellationToken): Promise { // See if we can find the command kernelspec - return Cancellation.race(() => this.isCommandSupported(KernelSpecCommand), cancelToken); + return Cancellation.race(() => this.isCommandSupported(JupyterCommands.KernelSpecCommand), cancelToken); } public connectToNotebookServer(uri: string | undefined, usingDarkTheme: boolean, useDefaultConfig: boolean, cancelToken?: CancellationToken, workingDir?: string): Promise { @@ -162,7 +152,7 @@ export class JupyterExecution implements IJupyterExecution, Disposable { }, cancelToken); } - public spawnNotebook = async (file: string): Promise => { + public async spawnNotebook(file: string): Promise { // First we find a way to start a notebook server const notebookCommand = await this.findBestCommand('notebook'); if (!notebookCommand) { @@ -175,9 +165,9 @@ export class JupyterExecution implements IJupyterExecution, Disposable { notebookCommand.exec(args, { throwOnStdErr: false, encoding: 'utf8' }).ignoreErrors(); } - public importNotebook = async (file: string, template: string): Promise => { + public async importNotebook(file: string, template: string): Promise { // First we find a way to start a nbconvert - const convert = await this.findBestCommand(ConvertCommand); + const convert = await this.findBestCommand(JupyterCommands.ConvertCommand); if (!convert) { throw new Error(localize.DataScience.jupyterNbConvertNotSupported()); } @@ -235,7 +225,7 @@ export class JupyterExecution implements IJupyterExecution, Disposable { @captureTelemetry(Telemetry.StartJupyter) private async startNotebookServer(useDefaultConfig: boolean, cancelToken?: CancellationToken): Promise<{ connection: IConnection; kernelSpec: IJupyterKernelSpec | undefined }> { // First we find a way to start a notebook server - const notebookCommand = await this.findBestCommand(NotebookCommand, cancelToken); + const notebookCommand = await this.findBestCommand(JupyterCommands.NotebookCommand, cancelToken); if (!notebookCommand) { throw new Error(localize.DataScience.jupyterNotSupported()); } @@ -302,7 +292,7 @@ export class JupyterExecution implements IJupyterExecution, Disposable { private getUsableJupyterPythonImpl = async (cancelToken?: CancellationToken): Promise => { // This should be the best interpreter for notebooks - const found = await this.findBestCommand(NotebookCommand, cancelToken); + const found = await this.findBestCommand(JupyterCommands.NotebookCommand, cancelToken); if (found) { return found.interpreter(); } @@ -333,14 +323,14 @@ export class JupyterExecution implements IJupyterExecution, Disposable { } private onSettingsChanged() { - // Do the same thing as dispose so that we regenerate - // all of our commands - this.dispose(); + // Clear our usableJupyterInterpreter so that we recompute our values + this.usablePythonInterpreter = undefined; + this.commands = {}; } private async addMatchingSpec(bestInterpreter: PythonInterpreter, cancelToken?: CancellationToken): Promise { const displayName = localize.DataScience.historyTitle(); - const ipykernelCommand = await this.findBestCommand(KernelCreateCommand, cancelToken); + const ipykernelCommand = await this.findBestCommand(JupyterCommands.KernelCreateCommand, cancelToken); // If this fails, then we just skip this spec try { @@ -351,7 +341,7 @@ export class JupyterExecution implements IJupyterExecution, Disposable { const result = await ipykernelCommand.exec(['install', '--user', '--name', name, '--display-name', `'${displayName}'`], { throwOnStdErr: true, encoding: 'utf8', token: cancelToken }); // Result should have our file name. - const match = PyKernelOutputRegEx.exec(result.stdout); + const match = RegExpValues.PyKernelOutputRegEx.exec(result.stdout); const diskPath = match && match !== null && match.length > 1 ? path.join(match[1], 'kernel.json') : await this.findSpecPath(name); // Make sure we delete this file at some point. When we close VS code is probably good. It will also be destroy when @@ -527,7 +517,7 @@ export class JupyterExecution implements IJupyterExecution, Disposable { } private async readSpec(kernelSpecOutputLine: string) : Promise { - const match = KernelSpecOutputRegEx.exec(kernelSpecOutputLine); + const match = RegExpValues.KernelSpecOutputRegEx.exec(kernelSpecOutputLine); if (match && match !== null && match.length > 2) { // Second match should be our path to the kernel spec const file = path.join(match[2], 'kernel.json'); @@ -544,7 +534,7 @@ export class JupyterExecution implements IJupyterExecution, Disposable { private enumerateSpecs = async (cancelToken?: CancellationToken): Promise<(JupyterKernelSpec | undefined)[]> => { if (await this.isKernelSpecSupported()) { - const kernelSpecCommand = await this.findBestCommand(KernelSpecCommand); + const kernelSpecCommand = await this.findBestCommand(JupyterCommands.KernelSpecCommand); if (kernelSpecCommand) { try { @@ -575,7 +565,7 @@ export class JupyterExecution implements IJupyterExecution, Disposable { if (interpreter && await this.doesModuleExist(command, interpreter, cancelToken) && !Cancellation.isCanceled(cancelToken)) { // Our command args are different based on the command. ipykernel is not a jupyter command - const args = command === KernelCreateCommand ? ['-m', command] : ['-m', 'jupyter', command]; + const args = command === JupyterCommands.KernelCreateCommand ? ['-m', command] : ['-m', 'jupyter', command]; return this.commandFactory.createInterpreterCommand(args, interpreter); } @@ -585,7 +575,7 @@ export class JupyterExecution implements IJupyterExecution, Disposable { private lookForJupyterInDirectory = async (pathToCheck: string): Promise => { try { const files = await this.fileSystem.getFiles(pathToCheck); - return files ? files.filter(s => CheckJupyterRegEx.test(path.basename(s))) : []; + return files ? files.filter(s => RegExpValues.CheckJupyterRegEx.test(path.basename(s))) : []; } catch (err) { this.logger.logWarning('Python Extension (fileSystem.getFiles):', err); } @@ -701,8 +691,8 @@ export class JupyterExecution implements IJupyterExecution, Disposable { const pythonService = await this.executionFactory.createActivatedEnvironment({ resource: undefined, interpreter }); try { // Special case for ipykernel - const actualModule = module === KernelCreateCommand ? module : 'jupyter'; - const args = module === KernelCreateCommand ? ['--version'] : [module, '--version']; + const actualModule = module === JupyterCommands.KernelCreateCommand ? module : 'jupyter'; + const args = module === JupyterCommands.KernelCreateCommand ? ['--version'] : [module, '--version']; const result = await pythonService.execModule(actualModule, args, newOptions); return !result.stderr; diff --git a/src/client/datascience/jupyter/jupyterExecutionFactory.ts b/src/client/datascience/jupyter/jupyterExecutionFactory.ts new file mode 100644 index 000000000000..17be3dd4456b --- /dev/null +++ b/src/client/datascience/jupyter/jupyterExecutionFactory.ts @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +'use strict'; +import { inject, injectable } from 'inversify'; +import { CancellationToken } from 'vscode'; + +import { ILiveShareApi, IWorkspaceService } from '../../common/application/types'; +import { IFileSystem } from '../../common/platform/types'; +import { IProcessServiceFactory, IPythonExecutionFactory } from '../../common/process/types'; +import { IAsyncDisposableRegistry, IConfigurationService, IDisposableRegistry, ILogger } from '../../common/types'; +import { IInterpreterService, IKnownSearchPathsForInterpreters, PythonInterpreter } from '../../interpreter/contracts'; +import { IServiceContainer } from '../../ioc/types'; +import { IJupyterCommandFactory, IJupyterExecution, IJupyterSessionManager, INotebookServer } from '../types'; +import { JupyterExecutionBase } from './jupyterExecution'; +import { GuestJupyterExecution } from './liveshare/guestJupyterExecution'; +import { HostJupyterExecution } from './liveshare/hostJupyterExecution'; +import { RoleBasedFactory } from './liveshare/roleBasedFactory'; + +type JupyterExecutionClassType = { + new(liveShare: ILiveShareApi, + executionFactory: IPythonExecutionFactory, + interpreterService: IInterpreterService, + processServiceFactory: IProcessServiceFactory, + knownSearchPaths: IKnownSearchPathsForInterpreters, + logger: ILogger, + disposableRegistry: IDisposableRegistry, + asyncRegistry: IAsyncDisposableRegistry, + fileSystem: IFileSystem, + sessionManager: IJupyterSessionManager, + workspace: IWorkspaceService, + configuration: IConfigurationService, + commandFactory : IJupyterCommandFactory, + serviceContainer: IServiceContainer): IJupyterExecution; +}; + +@injectable() +export class JupyterExecution implements IJupyterExecution { + + private executionFactory: RoleBasedFactory; + + constructor(@inject(ILiveShareApi) liveShare: ILiveShareApi, + @inject(IPythonExecutionFactory) pythonFactory: IPythonExecutionFactory, + @inject(IInterpreterService) interpreterService: IInterpreterService, + @inject(IProcessServiceFactory) processServiceFactory: IProcessServiceFactory, + @inject(IKnownSearchPathsForInterpreters) knownSearchPaths: IKnownSearchPathsForInterpreters, + @inject(ILogger) logger: ILogger, + @inject(IDisposableRegistry) disposableRegistry: IDisposableRegistry, + @inject(IAsyncDisposableRegistry) asyncRegistry: IAsyncDisposableRegistry, + @inject(IFileSystem) fileSystem: IFileSystem, + @inject(IJupyterSessionManager) sessionManager: IJupyterSessionManager, + @inject(IWorkspaceService) workspace: IWorkspaceService, + @inject(IConfigurationService) configuration: IConfigurationService, + @inject(IJupyterCommandFactory) commandFactory : IJupyterCommandFactory, + @inject(IServiceContainer) serviceContainer: IServiceContainer) { + this.executionFactory = new RoleBasedFactory( + liveShare, + JupyterExecutionBase, + HostJupyterExecution, + GuestJupyterExecution, + liveShare, + pythonFactory, + interpreterService, + processServiceFactory, + knownSearchPaths, + logger, + disposableRegistry, + asyncRegistry, + fileSystem, + sessionManager, + workspace, + configuration, + commandFactory, + serviceContainer + ); + } + + public async isNotebookSupported(cancelToken?: CancellationToken): Promise { + const execution = await this.executionFactory.get(); + return execution.isNotebookSupported(cancelToken); + } + public async isImportSupported(cancelToken?: CancellationToken): Promise { + const execution = await this.executionFactory.get(); + return execution.isImportSupported(cancelToken); + } + public async isKernelCreateSupported(cancelToken?: CancellationToken): Promise { + const execution = await this.executionFactory.get(); + return execution.isKernelCreateSupported(cancelToken); + } + public async isKernelSpecSupported(cancelToken?: CancellationToken): Promise { + const execution = await this.executionFactory.get(); + return execution.isKernelSpecSupported(cancelToken); + } + public async connectToNotebookServer(uri: string | undefined, usingDarkTheme: boolean, useDefaultConfig: boolean, cancelToken?: CancellationToken, workingDir?: string): Promise { + const execution = await this.executionFactory.get(); + return execution.connectToNotebookServer(uri, usingDarkTheme, useDefaultConfig, cancelToken, workingDir); + } + public async spawnNotebook(file: string): Promise { + const execution = await this.executionFactory.get(); + return execution.spawnNotebook(file); + } + public async importNotebook(file: string, template: string): Promise { + const execution = await this.executionFactory.get(); + return execution.importNotebook(file, template); + } + public async getUsableJupyterPython(cancelToken?: CancellationToken): Promise { + const execution = await this.executionFactory.get(); + return execution.getUsableJupyterPython(cancelToken); + } + public async dispose(): Promise { + const execution = await this.executionFactory.get(); + return execution.dispose(); + } +} diff --git a/src/client/datascience/jupyter/jupyterServer.ts b/src/client/datascience/jupyter/jupyterServer.ts index 2c1881896e48..60744f31ce00 100644 --- a/src/client/datascience/jupyter/jupyterServer.ts +++ b/src/client/datascience/jupyter/jupyterServer.ts @@ -6,30 +6,26 @@ import '../../common/extensions'; import { nbformat } from '@jupyterlab/coreutils'; import { Kernel, KernelMessage } from '@jupyterlab/services'; import * as fs from 'fs-extra'; -import { inject, injectable } from 'inversify'; import * as os from 'os'; import { Observable } from 'rxjs/Observable'; import { Subscriber } from 'rxjs/Subscriber'; -import * as vscode from 'vscode'; +import * as uuid from 'uuid/v4'; import { CancellationToken } from 'vscode-jsonrpc'; +import { ILiveShareApi } from '../../common/application/types'; import { CancellationError } from '../../common/cancellation'; -import { - IAsyncDisposable, - IAsyncDisposableRegistry, - IConfigurationService, - IDisposableRegistry, - ILogger -} from '../../common/types'; +import { IAsyncDisposableRegistry, IConfigurationService, IDisposableRegistry, ILogger } from '../../common/types'; import { createDeferred, Deferred, sleep } from '../../common/utils/async'; import * as localize from '../../common/utils/localize'; import { noop } from '../../common/utils/misc'; import { generateCells } from '../cellFactory'; import { concatMultilineString, stripComments } from '../common'; +import { Identifiers } from '../constants'; import { CellState, ICell, IConnection, + IDataScience, IJupyterKernelSpec, IJupyterSession, IJupyterSessionManager, @@ -112,27 +108,27 @@ class CellSubscriber { // This code is based on the examples here: // https://www.npmjs.com/package/@jupyterlab/services -@injectable() -export class JupyterServer implements INotebookServer, IAsyncDisposable { +export class JupyterServerBase implements INotebookServer { private session: IJupyterSession | undefined; private connInfo: IConnection | undefined; private workingDir: string | undefined; private sessionStartTime: number | undefined; - private onStatusChangedEvent: vscode.EventEmitter = new vscode.EventEmitter(); private pendingCellSubscriptions: CellSubscriber[] = []; private ranInitialSetup = false; private usingDarkTheme: boolean | undefined; constructor( - @inject(ILogger) private logger: ILogger, - @inject(IDisposableRegistry) private disposableRegistry: IDisposableRegistry, - @inject(IAsyncDisposableRegistry) private asyncRegistry: IAsyncDisposableRegistry, - @inject(IConfigurationService) private configService: IConfigurationService, - @inject(IJupyterSessionManager) private sessionManager: IJupyterSessionManager) { + liveShare: ILiveShareApi, + dataScience: IDataScience, + private logger: ILogger, + private disposableRegistry: IDisposableRegistry, + private asyncRegistry: IAsyncDisposableRegistry, + private configService: IConfigurationService, + private sessionManager: IJupyterSessionManager) { this.asyncRegistry.push(this); } - public connect = async (connInfo: IConnection, kernelSpec: IJupyterKernelSpec | undefined, usingDarkTheme: boolean, cancelToken?: CancellationToken, workingDir?: string): Promise => { + public async connect(connInfo: IConnection, kernelSpec: IJupyterKernelSpec | undefined, usingDarkTheme: boolean, cancelToken?: CancellationToken, workingDir?: string): Promise { // Save connection info. Determines if we need to change directory or not this.connInfo = connInfo; this.workingDir = workingDir; @@ -157,7 +153,6 @@ export class JupyterServer implements INotebookServer, IAsyncDisposable { } public dispose(): Promise { - this.onStatusChangedEvent.dispose(); return this.shutdown(); } @@ -195,7 +190,7 @@ export class JupyterServer implements INotebookServer, IAsyncDisposable { return deferred.promise; } - public setInitialDirectory = async (directory: string): Promise => { + public async setInitialDirectory(directory: string): Promise { // If we launched local and have no working directory call this on add code to change directory if (!this.workingDir && this.connInfo && this.connInfo.localLaunch) { await this.changeDirectoryIfPossible(directory); @@ -203,79 +198,73 @@ export class JupyterServer implements INotebookServer, IAsyncDisposable { } } - public executeObservable = (code: string, file: string, line: number, id?: string): Observable => { + public executeObservable(code: string, file: string, line: number, id?: string): Observable { + return this.executeObservableImpl(code, file, line, id, false); + } + + public executeSilently(code: string, cancelToken?: CancellationToken): Promise { // Do initial setup if necessary this.initialNotebookSetup(); - // If we have a session, execute the code now. - if (this.session) { - // Generate our cells ahead of time - const cells = generateCells(this.configService.getSettings().datascience, code, file, line, true, id); + // Create a deferred that we'll fire when we're done + const deferred = createDeferred(); - // Might have more than one (markdown might be split) - if (cells.length > 1) { - // We need to combine results - return this.combineObservables( - this.executeMarkdownObservable(cells[0]), - this.executeCodeObservable(cells[1])); - } else if (cells.length > 0) { - // Either markdown or or code - return this.combineObservables( - cells[0].data.cell_type === 'code' ? this.executeCodeObservable(cells[0]) : this.executeMarkdownObservable(cells[0])); - } + // Attempt to evaluate this cell in the jupyter notebook + const observable = this.executeObservableImpl(code, Identifiers.EmptyFileName, 0, uuid(), true); + let output: ICell[]; + + observable.subscribe( + (cells: ICell[]) => { + output = cells; + }, + (error) => { + deferred.reject(error); + }, + () => { + deferred.resolve(output); + }); + + if (cancelToken) { + this.disposableRegistry.push(cancelToken.onCancellationRequested(() => deferred.reject(new CancellationError()))); } - // Can't run because no session - return new Observable(subscriber => { - subscriber.error(new Error(localize.DataScience.sessionDisposed())); - subscriber.complete(); - }); + // Wait for the execution to finish + return deferred.promise; } - public executeSilently = (code: string, cancelToken?: CancellationToken): Promise => { - return new Promise((resolve, reject) => { + public async getSysInfo() : Promise { + // tslint:disable-next-line:no-multiline-string + const versionCells = await this.executeSilently(`import sys\r\nsys.version`); + // tslint:disable-next-line:no-multiline-string + const pathCells = await this.executeSilently(`import sys\r\nsys.executable`); + // tslint:disable-next-line:no-multiline-string + const notebookVersionCells = await this.executeSilently(`import notebook\r\nnotebook.version_info`); - // If we cancel, reject our promise - if (cancelToken) { - this.disposableRegistry.push(cancelToken.onCancellationRequested(() => reject(new CancellationError()))); - } + // Both should have streamed output + const version = versionCells.length > 0 ? this.extractStreamOutput(versionCells[0]).trimQuotes() : ''; + const notebookVersion = notebookVersionCells.length > 0 ? this.extractStreamOutput(notebookVersionCells[0]).trimQuotes() : ''; + const pythonPath = versionCells.length > 0 ? this.extractStreamOutput(pathCells[0]).trimQuotes() : ''; - // Do initial setup if necessary - this.initialNotebookSetup(); - - // If we have a session, execute the code now. - if (this.session) { - // Generate a new request and resolve when it's done. - const request = this.generateRequest(code, true); - - if (request) { - // // For debugging purposes when silently is failing. - // request.onIOPub = (msg: KernelMessage.IIOPubMessage) => { - // try { - // this.logger.logInformation(`Execute silently message ${msg.header.msg_type} : hasData=${'data' in msg.content}`); - // } catch (err) { - // this.logger.logError(err); - // } - // }; - - request.done.then(() => { - this.logger.logInformation(`Execute for ${code} silently finished.`); - resolve(); - }).catch(reject); - } else { - reject(new Error(localize.DataScience.sessionDisposed())); - } - } else { - reject(new Error(localize.DataScience.sessionDisposed())); - } - }); - } - - public get onStatusChanged(): vscode.Event { - return this.onStatusChangedEvent.event.bind(this.onStatusChangedEvent); + // Combine this data together to make our sys info + return { + data: { + cell_type: 'sys_info', + version: version, + notebook_version: localize.DataScience.notebookVersionFormat().format(notebookVersion), + path: pythonPath, + metadata: {}, + source: [], + message: '', // This will be filled in by the caller + connection: '' // This will be filled in by the caller (before getting to the output) + }, + id: uuid(), + file: '', + line: 0, + state: CellState.finished + }; } - public restartKernel = async (): Promise => { + public async restartKernel(): Promise { if (this.session) { // Update our start time so we don't keep sending responses this.sessionStartTime = Date.now(); @@ -297,7 +286,7 @@ export class JupyterServer implements INotebookServer, IAsyncDisposable { throw new Error(localize.DataScience.sessionDisposed()); } - public interruptKernel = async (timeoutMs: number): Promise => { + public async interruptKernel(timeoutMs: number): Promise { if (this.session) { // Keep track of our current time. If our start time gets reset, we // restarted the kernel. @@ -389,7 +378,58 @@ export class JupyterServer implements INotebookServer, IAsyncDisposable { }; } - private generateRequest = (code: string, silent: boolean): Kernel.IFuture | undefined => { + private extractStreamOutput(cell: ICell): string { + let result = ''; + if (cell.state === CellState.error || cell.state === CellState.finished) { + const outputs = cell.data.outputs as nbformat.IOutput[]; + if (outputs) { + outputs.forEach(o => { + if (o.output_type === 'stream') { + const stream = o as nbformat.IStream; + result = result.concat(stream.text.toString()); + } else { + const data = o.data; + if (data && data.hasOwnProperty('text/plain')) { + // tslint:disable-next-line:no-any + result = result.concat((data as any)['text/plain']); + } + } + }); + } + } + return result; + } + + private executeObservableImpl(code: string, file: string, line: number, id: string | undefined, silent?: boolean) : Observable { + // Do initial setup if necessary + this.initialNotebookSetup(); + + // If we have a session, execute the code now. + if (this.session) { + // Generate our cells ahead of time + const cells = generateCells(this.configService.getSettings().datascience, code, file, line, true, id); + + // Might have more than one (markdown might be split) + if (cells.length > 1) { + // We need to combine results + return this.combineObservables( + this.executeMarkdownObservable(cells[0]), + this.executeCodeObservable(cells[1], silent)); + } else if (cells.length > 0) { + // Either markdown or or code + return this.combineObservables( + cells[0].data.cell_type === 'code' ? this.executeCodeObservable(cells[0], silent) : this.executeMarkdownObservable(cells[0])); + } + } + + // Can't run because no session + return new Observable(subscriber => { + subscriber.error(new Error(localize.DataScience.sessionDisposed())); + subscriber.complete(); + }); + } + + private generateRequest = (code: string, silent?: boolean): Kernel.IFuture | undefined => { //this.logger.logInformation(`Executing code in jupyter : ${code}`) try { return this.session ? this.session.requestExecute( @@ -398,7 +438,7 @@ export class JupyterServer implements INotebookServer, IAsyncDisposable { code: code.replace(/\r\n/g, '\n'), stop_on_error: false, allow_stdin: false, - silent: silent + store_history: !silent // Silent actually means don't output anything. Store_history is what affects execution_count }, true ) : undefined; @@ -473,11 +513,11 @@ export class JupyterServer implements INotebookServer, IAsyncDisposable { } } - private handleCodeRequest = (subscriber: CellSubscriber) => { + private handleCodeRequest = (subscriber: CellSubscriber, silent?: boolean) => { // Generate a new request if we still can if (subscriber.isValid(this.sessionStartTime)) { - const request = this.generateRequest(concatMultilineString(stripComments(subscriber.cell.data.source)), false); + const request = this.generateRequest(concatMultilineString(stripComments(subscriber.cell.data.source)), silent); // tslint:disable-next-line:no-require-imports const jupyterLab = require('@jupyterlab/services') as typeof import('@jupyterlab/services'); @@ -534,7 +574,7 @@ export class JupyterServer implements INotebookServer, IAsyncDisposable { } - private executeCodeObservable(cell: ICell): Observable { + private executeCodeObservable(cell: ICell, silent?: boolean): Observable { return new Observable(subscriber => { // Tell our listener. NOTE: have to do this asap so that markdown cells don't get // run before our cells. @@ -548,7 +588,7 @@ export class JupyterServer implements INotebookServer, IAsyncDisposable { // Attempt to change to the current directory. When that finishes // send our real request - this.handleCodeRequest(cellSubscriber); + this.handleCodeRequest(cellSubscriber, silent); }); } @@ -567,12 +607,6 @@ export class JupyterServer implements INotebookServer, IAsyncDisposable { } private handleStatusMessage(msg: KernelMessage.IStatusMsg, cell: ICell) { - if (msg.content.execution_state === 'busy') { - this.onStatusChangedEvent.fire(true); - } else { - this.onStatusChangedEvent.fire(false); - } - // Status change to idle generally means we finished. Not sure how to // make sure of this. Maybe only bother if an interrupt if (msg.content.execution_state === 'idle' && cell.state !== CellState.error) { diff --git a/src/client/datascience/jupyter/jupyterServerFactory.ts b/src/client/datascience/jupyter/jupyterServerFactory.ts new file mode 100644 index 000000000000..0cf12eaef576 --- /dev/null +++ b/src/client/datascience/jupyter/jupyterServerFactory.ts @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +'use strict'; +import '../../common/extensions'; + +import { inject, injectable } from 'inversify'; +import { Observable } from 'rxjs/Observable'; +import { CancellationToken } from 'vscode-jsonrpc'; + +import { ILiveShareApi } from '../../common/application/types'; +import { IAsyncDisposableRegistry, IConfigurationService, IDisposableRegistry, ILogger } from '../../common/types'; +import { + ICell, + IConnection, + IDataScience, + IJupyterKernelSpec, + IJupyterSessionManager, + INotebookServer, + InterruptResult +} from '../types'; +import { JupyterServerBase } from './jupyterServer'; +import { GuestJupyterServer } from './liveshare/guestJupyterServer'; +import { HostJupyterServer } from './liveshare/hostJupyterServer'; +import { RoleBasedFactory } from './liveshare/roleBasedFactory'; + +type JupyterServerClassType = { + new(liveShare: ILiveShareApi, + dataScience: IDataScience, + logger: ILogger, + disposableRegistry: IDisposableRegistry, + asyncRegistry: IAsyncDisposableRegistry, + configService: IConfigurationService, + sessionManager: IJupyterSessionManager): INotebookServer; +}; + +@injectable() +export class JupyterServer implements INotebookServer { + private serverFactory: RoleBasedFactory; + + private connInfo : IConnection | undefined; + + constructor( + @inject(ILiveShareApi) liveShare: ILiveShareApi, + @inject(IDataScience) dataScience: IDataScience, + @inject(ILogger) logger: ILogger, + @inject(IDisposableRegistry) disposableRegistry: IDisposableRegistry, + @inject(IAsyncDisposableRegistry) asyncRegistry: IAsyncDisposableRegistry, + @inject(IConfigurationService) configService: IConfigurationService, + @inject(IJupyterSessionManager) sessionManager: IJupyterSessionManager) { + this.serverFactory = new RoleBasedFactory( + liveShare, + JupyterServerBase, + HostJupyterServer, + GuestJupyterServer, + liveShare, + dataScience, + logger, + disposableRegistry, + asyncRegistry, + configService, + sessionManager + ); + } + + public async connect(connInfo: IConnection, kernelSpec: IJupyterKernelSpec | undefined, usingDarkTheme: boolean, cancelToken?: CancellationToken, workingDir?: string): Promise { + this.connInfo = connInfo; + const server = await this.serverFactory.get(); + return server.connect(connInfo, kernelSpec, usingDarkTheme, cancelToken, workingDir); + } + + public async shutdown(): Promise { + const server = await this.serverFactory.get(); + return server.shutdown(); + } + + public async dispose(): Promise { + const server = await this.serverFactory.get(); + return server.dispose(); + } + + public async waitForIdle(): Promise { + const server = await this.serverFactory.get(); + return server.waitForIdle(); + } + + public async execute(code: string, file: string, line: number, cancelToken?: CancellationToken): Promise { + const server = await this.serverFactory.get(); + return server.execute(code, file, line, cancelToken); + } + + public async setInitialDirectory(directory: string): Promise { + const server = await this.serverFactory.get(); + return server.setInitialDirectory(directory); + } + + public executeObservable(code: string, file: string, line: number, id?: string): Observable { + // Create a wrapper observable around the actual server (because we have to wait for a promise) + return new Observable(subscriber => { + this.serverFactory.get().then(s => { + s.executeObservable(code, file, line, id) + .forEach(n => subscriber.next(n), Promise) + .then(f => subscriber.complete()) + .catch(e => subscriber.error(e)); + }, + r => { + subscriber.error(r); + subscriber.complete(); + }); + }); + } + + public async executeSilently(code: string, cancelToken?: CancellationToken): Promise { + const server = await this.serverFactory.get(); + return server.dispose(); + } + + public async restartKernel(): Promise { + const server = await this.serverFactory.get(); + return server.restartKernel(); + } + + public async interruptKernel(timeoutMs: number): Promise { + const server = await this.serverFactory.get(); + return server.interruptKernel(timeoutMs); + } + + // Return a copy of the connection information that this server used to connect with + public getConnectionInfo(): IConnection | undefined { + return this.connInfo; + } + + public async getSysInfo() : Promise { + const server = await this.serverFactory.get(); + return server.getSysInfo(); + } +} diff --git a/src/client/datascience/jupyter/jupyterSession.ts b/src/client/datascience/jupyter/jupyterSession.ts index e0e3daf92a5e..e84140ef8771 100644 --- a/src/client/datascience/jupyter/jupyterSession.ts +++ b/src/client/datascience/jupyter/jupyterSession.ts @@ -132,7 +132,7 @@ export class JupyterSession implements IJupyterSession { } private onStatusChanged(s: Session.ISession, a: Kernel.Status) { - if (a === 'starting') { + if (a === 'starting' && this.onRestartedEvent) { this.onRestartedEvent.fire(); } } @@ -162,7 +162,9 @@ export class JupyterSession implements IJupyterSession { if (this.session) { // Shutdown may fail if the process has been killed await Promise.race([this.session.shutdown(), sleep(100)]); - this.session.dispose(); + if (this.session) { + this.session.dispose(); + } } if (this.sessionManager) { this.sessionManager.dispose(); diff --git a/src/client/datascience/jupyter/jupyterSessionManager.ts b/src/client/datascience/jupyter/jupyterSessionManager.ts index 1cdc9318eb13..7da8c7d4bab7 100644 --- a/src/client/datascience/jupyter/jupyterSessionManager.ts +++ b/src/client/datascience/jupyter/jupyterSessionManager.ts @@ -26,18 +26,20 @@ export class JupyterSessionManager implements IJupyterSessionManager { } public async getActiveKernelSpecs(connection: IConnection) : Promise { - // Use our connection to create a session manager - const serverSettings = ServerConnection.makeSettings( - { - baseUrl: connection.baseUrl, - token: connection.token, - pageUrl: '', - // A web socket is required to allow token authentication (what if there is no token authentication?) - wsUrl: connection.baseUrl.replace('http', 'ws'), - init: { cache: 'no-store', credentials: 'same-origin' } - }); - const sessionManager = new SessionManager({ serverSettings: serverSettings }); + let sessionManager: SessionManager | undefined ; try { + // Use our connection to create a session manager + const serverSettings = ServerConnection.makeSettings( + { + baseUrl: connection.baseUrl, + token: connection.token, + pageUrl: '', + // A web socket is required to allow token authentication (what if there is no token authentication?) + wsUrl: connection.baseUrl.replace('http', 'ws'), + init: { cache: 'no-store', credentials: 'same-origin' } + }); + sessionManager = new SessionManager({ serverSettings: serverSettings }); + // Ask the session manager to refresh its list of kernel specs. await sessionManager.refreshSpecs(); diff --git a/src/client/datascience/jupyter/liveshare/guestJupyterExecution.ts b/src/client/datascience/jupyter/liveshare/guestJupyterExecution.ts new file mode 100644 index 000000000000..fb0ec673de82 --- /dev/null +++ b/src/client/datascience/jupyter/liveshare/guestJupyterExecution.ts @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +'use strict'; +import { injectable } from 'inversify'; +import { CancellationToken } from 'vscode'; +import * as vsls from 'vsls/vscode'; + +import { ILiveShareApi, IWorkspaceService } from '../../../common/application/types'; +import { IFileSystem } from '../../../common/platform/types'; +import { IProcessServiceFactory, IPythonExecutionFactory } from '../../../common/process/types'; +import { IAsyncDisposableRegistry, IConfigurationService, IDisposableRegistry, ILogger } from '../../../common/types'; +import * as localize from '../../../common/utils/localize'; +import { IInterpreterService, IKnownSearchPathsForInterpreters, PythonInterpreter } from '../../../interpreter/contracts'; +import { IServiceContainer } from '../../../ioc/types'; +import { LiveShare, LiveShareCommands } from '../../constants'; +import { IConnection, IJupyterCommandFactory, IJupyterSessionManager, INotebookServer } from '../../types'; +import { JupyterConnectError } from '../jupyterConnectError'; +import { JupyterExecutionBase } from '../jupyterExecution'; +import { waitForGuestService } from './utils'; + +// This class is really just a wrapper around a jupyter execution that also provides a shared live share service +@injectable() +export class GuestJupyterExecution extends JupyterExecutionBase { + + private serviceProxy: Promise; + private runningServer : INotebookServer | undefined; + + constructor( + private liveShare: ILiveShareApi, + executionFactory: IPythonExecutionFactory, + interpreterService: IInterpreterService, + processServiceFactory: IProcessServiceFactory, + knownSearchPaths: IKnownSearchPathsForInterpreters, + logger: ILogger, + disposableRegistry: IDisposableRegistry, + asyncRegistry: IAsyncDisposableRegistry, + fileSystem: IFileSystem, + sessionManager: IJupyterSessionManager, + workspace: IWorkspaceService, + configuration: IConfigurationService, + commandFactory : IJupyterCommandFactory, + serviceContainer: IServiceContainer) { + super( + liveShare, + executionFactory, + interpreterService, + processServiceFactory, + knownSearchPaths, + logger, + disposableRegistry, + asyncRegistry, + fileSystem, + sessionManager, + workspace, + configuration, + commandFactory, + serviceContainer); + // Create the shared service proxy + this.serviceProxy = this.startSharedProxy(); + asyncRegistry.push(this); + } + + public async dispose() : Promise { + await super.dispose(); + + if (this.runningServer) { + return this.runningServer.dispose(); + } + } + + public async isNotebookSupported(cancelToken?: CancellationToken): Promise { + return this.checkSupported(LiveShareCommands.isNotebookSupported, cancelToken); + } + public isImportSupported(cancelToken?: CancellationToken): Promise { + return this.checkSupported(LiveShareCommands.isImportSupported, cancelToken); + } + public isKernelCreateSupported(cancelToken?: CancellationToken): Promise { + return this.checkSupported(LiveShareCommands.isKernelCreateSupported, cancelToken); + } + public isKernelSpecSupported(cancelToken?: CancellationToken): Promise { + return this.checkSupported(LiveShareCommands.isKernelSpecSupported, cancelToken); + } + public async connectToNotebookServer(uri: string, usingDarkTheme: boolean, useDefaultConfig: boolean, cancelToken?: CancellationToken, workingDir?: string): Promise { + // We only have a single server at a time. This object should go away when the server goes away + if (!this.runningServer) { + + // Create the server on the remote machine. It should return an IConnection we can use to build a remote uri + const proxy = await this.serviceProxy; + if (proxy) { + const connection : IConnection = await proxy.request(LiveShareCommands.connectToNotebookServer, [usingDarkTheme, useDefaultConfig, workingDir], cancelToken); + + // If that works, then treat this as a remote server and connect to it + if (connection && connection.baseUrl) { + const newUri = `${connection.baseUrl}?token=${connection.token}`; + this.runningServer = await super.connectToNotebookServer(newUri, usingDarkTheme, useDefaultConfig, cancelToken); + } + } + + if (!this.runningServer) { + throw new JupyterConnectError(localize.DataScience.liveShareConnectFailure()); + } + } + + return this.runningServer; + } + public spawnNotebook(file: string): Promise { + // Not supported in liveshare + throw new Error(localize.DataScience.liveShareCannotSpawnNotebooks()); + } + public importNotebook(file: string, template: string): Promise { + // Not supported in liveshare + throw new Error(localize.DataScience.liveShareCannotImportNotebooks()); + } + public async getUsableJupyterPython(cancelToken?: CancellationToken): Promise { + const proxy = await this.serviceProxy; + if (proxy) { + return proxy.request(LiveShareCommands.getUsableJupyterPython, [], cancelToken); + } + } + + private async startSharedProxy() : Promise { + const api = await this.liveShare.getApi(); + if (api) { + return waitForGuestService(api, LiveShare.JupyterExecutionService); + } + return null; + } + + private async checkSupported(command: string, cancelToken?: CancellationToken) : Promise { + // Make a remote call on the proxy + const proxy = await this.serviceProxy; + if (proxy) { + const result = await proxy.request(command, [], cancelToken); + return result as boolean; + } + + return false; + } +} diff --git a/src/client/datascience/jupyter/liveshare/guestJupyterServer.ts b/src/client/datascience/jupyter/liveshare/guestJupyterServer.ts new file mode 100644 index 000000000000..07322666432d --- /dev/null +++ b/src/client/datascience/jupyter/liveshare/guestJupyterServer.ts @@ -0,0 +1,238 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +'use strict'; +import { Observable } from 'rxjs/Observable'; +import { Subscriber } from 'rxjs/Subscriber'; +import { CancellationToken } from 'vscode-jsonrpc'; +import * as vsls from 'vsls/vscode'; + +import { ILiveShareApi } from '../../../common/application/types'; +import { CancellationError } from '../../../common/cancellation'; +import { IAsyncDisposableRegistry, IConfigurationService, IDisposableRegistry, ILogger } from '../../../common/types'; +import { createDeferred, Deferred } from '../../../common/utils/async'; +import * as localize from '../../../common/utils/localize'; +import { LiveShare, LiveShareCommands } from '../../constants'; +import { + ICell, + IConnection, + IDataScience, + IJupyterKernelSpec, + IJupyterSessionManager, + INotebookServer, + InterruptResult +} from '../../types'; +import { IExecuteObservableResponse, IInterruptResponse, IServerResponse, ServerResponseType } from './types'; +import { waitForGuestService } from './utils'; + +export class GuestJupyterServer implements INotebookServer { + private connInfo : IConnection | undefined; + private responseQueue : IServerResponse [] = []; + private waitingQueue : { deferred: Deferred; predicate(r: IServerResponse) : boolean }[] = []; + private sharedService: Promise; + + constructor( + private liveShare: ILiveShareApi, + private dataScience: IDataScience, + logger: ILogger, + private disposableRegistry: IDisposableRegistry, + asyncRegistry: IAsyncDisposableRegistry, + configService: IConfigurationService, + sessionManager: IJupyterSessionManager) { + this.sharedService = this.startSharedServiceProxy(); + } + + public async connect(connInfo: IConnection, kernelSpec: IJupyterKernelSpec | undefined, usingDarkTheme: boolean, cancelToken?: CancellationToken, workingDir?: string): Promise { + this.connInfo = connInfo; + return Promise.resolve(); + } + + public shutdown(): Promise { + return Promise.resolve(); + } + + public dispose(): Promise { + return Promise.resolve(); + } + + public waitForIdle(): Promise { + return Promise.resolve(); + } + + public async execute(code: string, file: string, line: number, cancelToken?: CancellationToken): Promise { + // Create a deferred that we'll fire when we're done + const deferred = createDeferred(); + + // Attempt to evaluate this cell in the jupyter notebook + const observable = this.executeObservable(code, file, line); + let output: ICell[]; + + observable.subscribe( + (cells: ICell[]) => { + output = cells; + }, + (error) => { + deferred.reject(error); + }, + () => { + deferred.resolve(output); + }); + + if (cancelToken) { + this.disposableRegistry.push(cancelToken.onCancellationRequested(() => deferred.reject(new CancellationError()))); + } + + // Wait for the execution to finish + return deferred.promise; + } + + public setInitialDirectory(directory: string): Promise { + // Ignore this command on this side + return Promise.resolve(); + } + + public executeObservable(code: string, file: string, line: number, id?: string): Observable { + // Create a wrapper observable around the actual server + return new Observable(subscriber => { + // Wait for the observable responses to come in + this.waitForObservable(subscriber, code, file, line, id) + .catch(e => { + subscriber.error(e); + subscriber.complete(); + }); + }); + } + + public async executeSilently(code: string, cancelToken?: CancellationToken): Promise { + // We don't need the result from this. It should have already happened on the host side + return Promise.resolve(); + } + + public async restartKernel(): Promise { + await this.waitForResponse(ServerResponseType.Restart); + } + + public async interruptKernel(timeoutMs: number): Promise { + const response = await this.waitForResponse(ServerResponseType.Restart); + return (response as IInterruptResponse).result; + } + + // Return a copy of the connection information that this server used to connect with + public getConnectionInfo(): IConnection | undefined { + return this.connInfo; + } + + public async getSysInfo() : Promise { + // This is a special case. Ask the shared server + const server = await this.sharedService; + if (server) { + const result = await server.request(LiveShareCommands.getSysInfo, []); + return (result as ICell); + } + } + + private async startSharedServiceProxy() : Promise { + const api = await this.liveShare.getApi(); + + if (api) { + // Wait for the host to be setup too. + const service = await waitForGuestService(api, LiveShare.JupyterServerSharedService); + + // Wait for sync up + const synced = service !== null ? await service.request(LiveShareCommands.syncRequest, []) : undefined; + if (!synced) { + throw new Error(localize.DataScience.liveShareSyncFailure()); + } + + if (service !== null) { + // Listen to responses + service.onNotify(LiveShareCommands.serverResponse, this.onServerResponse); + + // Request all of the responses since this guest was started. We likely missed a bunch + service.notify(LiveShareCommands.catchupRequest, { since: this.dataScience.activationStartTime }); + } + return service; + } + + return null; + } + + private onServerResponse = (args: Object) => { + // Args should be of type ServerResponse. Stick in our queue if so. + if (args.hasOwnProperty('type')) { + this.responseQueue.push(args as IServerResponse); + + // Check for any waiters. + this.dispatchResponses(); + } + } + + private async waitForObservable(subscriber: Subscriber, code: string, file: string, line: number, id?: string) : Promise { + let pos = 0; + let foundId = id; + let cells: ICell[] | undefined = []; + while (cells !== undefined) { + // Find all matches in order + const response = await this.waitForSpecificResponse(r => { + return (r.pos === pos) && + (foundId === r.id || !foundId) && + (code === r.code) && + (!r.cells || (r.cells && r.cells[0].file === file && r.cells[0].line === line)); + }); + if (response.cells) { + subscriber.next(response.cells); + pos += 1; + foundId = response.id; + } + cells = response.cells; + } + subscriber.complete(); + } + + private waitForSpecificResponse(predicate: (response: T) => boolean) : Promise { + // See if we have any responses right now with this type + const index = this.responseQueue.findIndex(r => predicate(r as T)); + if (index >= 0) { + // Pull off the match + const match = this.responseQueue[index]; + + // Remove from the response queue if necessary + this.responseQueue.splice(index, 1); + + // Return this single item + return Promise.resolve(match as T); + } else { + // We have to wait for a new input to happen + const waitable = { deferred: createDeferred(), predicate }; + this.waitingQueue.push(waitable); + return waitable.deferred.promise; + } + } + + private waitForResponse(type: ServerResponseType) : Promise { + return this.waitForSpecificResponse(r => r.type === type); + } + + private dispatchResponses() { + // Look through all of our responses that are queued up and see if they make a + // waiting promise resolve + for (let i = 0; i < this.responseQueue.length; i += 1) { + const response = this.responseQueue[i]; + const matchIndex = this.waitingQueue.findIndex(w => w.predicate(response)); + if (matchIndex >= 0) { + this.waitingQueue[matchIndex].deferred.resolve(response); + this.waitingQueue.splice(matchIndex, 1); + this.responseQueue.splice(i, 1); + i -= 1; // Offset the addition as we removed this item + } + } + } + + // Should turn this back on to make sure we aren't matching responses from too long ago. Our + // match is imperfect at the moment. + // tslint:disable-next-line:no-unused-variable + private isAllowed(response: IServerResponse, time: number) : boolean { + const debug = /--debug|--inspect/.test(process.execArgv.join(' ')); + const range = debug ? LiveShare.ResponseRange * 30 : LiveShare.ResponseRange; + return Math.abs(response.time - time) < range; + } +} diff --git a/src/client/datascience/jupyter/liveshare/hostJupyterExecution.ts b/src/client/datascience/jupyter/liveshare/hostJupyterExecution.ts new file mode 100644 index 000000000000..7733cf9563f5 --- /dev/null +++ b/src/client/datascience/jupyter/liveshare/hostJupyterExecution.ts @@ -0,0 +1,164 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +'use strict'; +import '../../../common/extensions'; + +import * as os from 'os'; +import { CancellationToken } from 'vscode'; +import * as vsls from 'vsls/vscode'; + +import { ILiveShareApi, IWorkspaceService } from '../../../common/application/types'; +import { IFileSystem } from '../../../common/platform/types'; +import { IProcessServiceFactory, IPythonExecutionFactory } from '../../../common/process/types'; +import { IAsyncDisposableRegistry, IConfigurationService, IDisposableRegistry, ILogger } from '../../../common/types'; +import * as localize from '../../../common/utils/localize'; +import { noop } from '../../../common/utils/misc'; +import { IInterpreterService, IKnownSearchPathsForInterpreters } from '../../../interpreter/contracts'; +import { IServiceContainer } from '../../../ioc/types'; +import { LiveShare, LiveShareCommands, RegExpValues } from '../../constants'; +import { IConnection, IJupyterCommandFactory, IJupyterSessionManager, INotebookServer } from '../../types'; +import { JupyterExecutionBase } from '../jupyterExecution'; +import { waitForHostService } from './utils'; + +// tslint:disable:no-any + +// This class is really just a wrapper around a jupyter execution that also provides a shared live share service +export class HostJupyterExecution extends JupyterExecutionBase { + + private started: Promise; + private runningServer : INotebookServer | undefined; + + constructor( + private liveShare: ILiveShareApi, + executionFactory: IPythonExecutionFactory, + interpreterService: IInterpreterService, + processServiceFactory: IProcessServiceFactory, + knownSearchPaths: IKnownSearchPathsForInterpreters, + logger: ILogger, + disposableRegistry: IDisposableRegistry, + asyncRegistry: IAsyncDisposableRegistry, + fileSystem: IFileSystem, + sessionManager: IJupyterSessionManager, + workspace: IWorkspaceService, + configuration: IConfigurationService, + commandFactory : IJupyterCommandFactory, + serviceContainer: IServiceContainer) { + super( + liveShare, + executionFactory, + interpreterService, + processServiceFactory, + knownSearchPaths, + logger, + disposableRegistry, + asyncRegistry, + fileSystem, + sessionManager, + workspace, + configuration, + commandFactory, + serviceContainer); + + // Create the shared service for the guest(s) to listen to. + this.started = this.startSharedService(); + asyncRegistry.push(this); + } + + public async dispose() : Promise { + await super.dispose(); + const api = await this.started; + if (api) { + await api.unshareService(LiveShare.JupyterExecutionService); + } + + if (this.runningServer) { + return this.runningServer.dispose(); + } + } + + public async connectToNotebookServer(uri: string | undefined, usingDarkTheme: boolean, useDefaultConfig: boolean, cancelToken?: CancellationToken, workingDir?: string): Promise { + // We only have a single server at a time. This object should go away when the server goes away + if (!this.runningServer) { + // Create the server + this.runningServer = await super.connectToNotebookServer(uri, usingDarkTheme, useDefaultConfig, cancelToken, workingDir); + + // Then using the liveshare api, port forward whatever port is being used by the server + // Note: Liveshare can actually change this value on the guest. So on the guest side we need to listen + // to an event they are going to add to their api. + if (!uri && this.runningServer) { + const api = await this.started; + if (api && api.session && api.session.role === vsls.Role.Host) { + const connectionInfo = this.runningServer.getConnectionInfo(); + if (connectionInfo) { + const portMatch = RegExpValues.ExtractPortRegex.exec(connectionInfo.baseUrl); + if (portMatch && portMatch.length > 1) { + await api.shareServer({ port: parseInt(portMatch[1], 10), displayName: localize.DataScience.liveShareHostFormat().format(os.hostname()) }); + } + } + } + } + } + + return this.runningServer; + } + + private async startSharedService() : Promise { + const api = await this.liveShare.getApi(); + + if (api) { + const service = await waitForHostService(api, LiveShare.JupyterExecutionService); + + // Register handlers for all of the supported remote calls + if (service !== null) { + service.onRequest(LiveShareCommands.isNotebookSupported, this.onRemoteIsNotebookSupported); + service.onRequest(LiveShareCommands.isImportSupported, this.onRemoteIsImportSupported); + service.onRequest(LiveShareCommands.isKernelCreateSupported, this.onRemoteIsKernelCreateSupported); + service.onRequest(LiveShareCommands.isKernelSpecSupported, this.onRemoteIsKernelSpecSupported); + service.onRequest(LiveShareCommands.connectToNotebookServer, this.onRemoteConnectToNotebookServer); + service.onRequest(LiveShareCommands.getUsableJupyterPython, this.onRemoteGetUsableJupyterPython); + } else { + throw new Error(localize.DataScience.liveShareServiceFailure().format(LiveShare.JupyterExecutionService)); + } + } + + return api; + } + private onRemoteIsNotebookSupported = (args: any[], cancellation: CancellationToken): Promise => { + // Just call local + return this.isNotebookSupported(cancellation); + } + + private onRemoteIsImportSupported = (args: any[], cancellation: CancellationToken): Promise => { + // Just call local + return this.isImportSupported(cancellation); + } + + private onRemoteIsKernelCreateSupported = (args: any[], cancellation: CancellationToken): Promise => { + // Just call local + return this.isKernelCreateSupported(cancellation); + } + private onRemoteIsKernelSpecSupported = (args: any[], cancellation: CancellationToken): Promise => { + // Just call local + return this.isKernelSpecSupported(cancellation); + } + + private onRemoteConnectToNotebookServer = async (args: any[], cancellation: CancellationToken): Promise => { + // Connect to the local server. THe local server should have started the port forwarding already + const localServer = await this.connectToNotebookServer(undefined, args[0], args[1], cancellation, args[2]); + + // Extract the URI and token for the other side + if (localServer) { + // The other side should be using 'localhost' for anything it's port forwarding. That should just remap + // on the guest side. However we need to eliminate the dispose method. Methods are not serializable + const connectionInfo = localServer.getConnectionInfo(); + if (connectionInfo) { + return { baseUrl: connectionInfo.baseUrl, token: connectionInfo.token, localLaunch: false, dispose: noop }; + } + } + } + + private onRemoteGetUsableJupyterPython = (args: any[], cancellation: CancellationToken): Promise => { + // Just call local + return this.getUsableJupyterPython(cancellation); + } +} diff --git a/src/client/datascience/jupyter/liveshare/hostJupyterServer.ts b/src/client/datascience/jupyter/liveshare/hostJupyterServer.ts new file mode 100644 index 000000000000..c24c2a7eae14 --- /dev/null +++ b/src/client/datascience/jupyter/liveshare/hostJupyterServer.ts @@ -0,0 +1,190 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +'use strict'; +import '../../../common/extensions'; + +import { Observable } from 'rxjs/Observable'; +import * as uuid from 'uuid/v4'; +import * as vscode from 'vscode'; +import { CancellationToken } from 'vscode-jsonrpc'; +import * as vsls from 'vsls/vscode'; + +import { ILiveShareApi } from '../../../common/application/types'; +import { IAsyncDisposableRegistry, IConfigurationService, IDisposableRegistry, ILogger } from '../../../common/types'; +import * as localize from '../../../common/utils/localize'; +import { LiveShare, LiveShareCommands } from '../../constants'; +import { ICell, IDataScience, IJupyterSessionManager, InterruptResult } from '../../types'; +import { JupyterServerBase } from '../jupyterServer'; +import { ICatchupRequest, IResponseMapping, IServerResponse, ServerResponseType } from './types'; +import { waitForHostService } from './utils'; + +// tslint:disable:no-any + +export class HostJupyterServer extends JupyterServerBase { + private service: Promise; + private responseBacklog : { responseTime: number; response: IServerResponse }[] = []; + + constructor( + private liveShare: ILiveShareApi, + dataScience: IDataScience, + logger: ILogger, + disposableRegistry: IDisposableRegistry, + asyncRegistry: IAsyncDisposableRegistry, + configService: IConfigurationService, + sessionManager: IJupyterSessionManager) { + super(liveShare, dataScience, logger, disposableRegistry, asyncRegistry, configService, sessionManager); + this.service = this.startSharedService(); + } + + public async dispose(): Promise { + await super.dispose(); + const api = await this.liveShare.getApi(); + if (api !== null) { + return api.unshareService(LiveShare.JupyterServerSharedService); + } + } + + public executeObservable(code: string, file: string, line: number, id?: string): Observable { + try { + const inner = super.executeObservable(code, file, line, id); + + // Wrap the observable returned so we can listen to it too + return this.wrapObservableResult(code, inner, id); + + } catch (exc) { + this.postException(exc); + throw exc; + } + + } + + public async restartKernel(): Promise { + try { + const time = Date.now(); + await super.restartKernel(); + return this.postResult(ServerResponseType.Restart, {type: ServerResponseType.Restart, time}); + } catch (exc) { + this.postException(exc); + throw exc; + } + } + + public async interruptKernel(timeoutMs: number): Promise { + try { + const time = Date.now(); + const result = await super.interruptKernel(timeoutMs); + await this.postResult(ServerResponseType.Interrupt, {type: ServerResponseType.Interrupt, time, result}); + return result; + } catch (exc) { + this.postException(exc); + throw exc; + } + } + + private translateCellForGuest(api: vsls.LiveShare | null, cell: ICell) : ICell { + const copy = {...cell}; + if (api !== null) { + copy.file = api.convertLocalUriToShared(vscode.Uri.file(copy.file)).fsPath; + } + return copy; + } + + private async startSharedService() : Promise { + const api = await this.liveShare.getApi(); + + if (api) { + const service = await waitForHostService(api, LiveShare.JupyterServerSharedService); + + // Attach event handlers to different requests + if (service !== null) { + service.onRequest(LiveShareCommands.syncRequest, (args: object, cancellation: CancellationToken) => this.onSync()); + service.onRequest(LiveShareCommands.getSysInfo, (args: any[], cancellation: CancellationToken) => this.onGetSysInfoRequest(service, cancellation)); + service.onNotify(LiveShareCommands.catchupRequest, (args: object) => this.onCatchupRequest(service, args)); + } else { + throw new Error(localize.DataScience.liveShareServiceFailure().format(LiveShare.JupyterServerSharedService)); + } + + return service; + } + } + + private onSync() : Promise { + return Promise.resolve(true); + } + + private onGetSysInfoRequest(service: vsls.SharedService, cancellation: CancellationToken) : Promise { + // Get the sys info from our local server + return super.getSysInfo(); + } + + private onCatchupRequest(service: vsls.SharedService, args: object) { + if (args.hasOwnProperty('since')) { + const request = args as ICatchupRequest; + + // Send results for all of the responses that are after the start time + this.responseBacklog.forEach(r => { + if (r.responseTime >= request.since) { + service.notify(LiveShareCommands.serverResponse, r.response); + + // Keep them in the response backlog as another guest may need them too + } + }); + } + } + + private wrapObservableResult(code: string, observable: Observable, id?: string) : Observable { + return new Observable(subscriber => { + // We need the api to translate cells + this.liveShare.getApi().then((api) => { + // Generate a new id or use the one passed in to identify everything that happened + const newId = id ? id : uuid(); + let pos = 0; + + // Listen to all of the events on the observable passed in. + observable.subscribe(cells => { + // Forward to the next listener + subscriber.next(cells); + + // Send across to the guest side + const translated = cells.map(c => this.translateCellForGuest(api, c)); + this.postObservableNext(code, pos, translated, newId).catch(e => subscriber.error(e)); + pos += 1; + }, + e => { + subscriber.error(e); + this.postException(e); + }, + () => { + subscriber.complete(); + this.postObservableComplete(code, pos, newId); + }); + + }).ignoreErrors(); + }); + } + + private postObservableNext(code: string, pos: number, cells: ICell[], id: string) : Promise { + return this.postResult(ServerResponseType.ExecuteObservable, { code, pos, type: ServerResponseType.ExecuteObservable, cells, id, time: Date.now() }); + } + + private postObservableComplete(code: string, pos: number, id: string) { + this.postResult(ServerResponseType.ExecuteObservable, { code, pos, type: ServerResponseType.ExecuteObservable, cells: undefined, id, time: Date.now() }).ignoreErrors(); + } + + private postException(exc: any) { + this.postResult(ServerResponseType.Exception, {type: ServerResponseType.Exception, time: Date.now(), message: exc.toString()}).ignoreErrors(); + } + + private async postResult(type: T, result: R[T]) : Promise { + const service = await this.service; + if (service) { + const typedResult = ((result as any) as IServerResponse); + if (typedResult) { + service.notify(LiveShareCommands.serverResponse, typedResult); + + // Need to also save in memory for those guests that are in the middle of starting up + this.responseBacklog.push({ responseTime: Date.now(), response: typedResult }); + } + } + } +} diff --git a/src/client/datascience/jupyter/liveshare/roleBasedFactory.ts b/src/client/datascience/jupyter/liveshare/roleBasedFactory.ts new file mode 100644 index 000000000000..f5fb136161b7 --- /dev/null +++ b/src/client/datascience/jupyter/liveshare/roleBasedFactory.ts @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +'use strict'; +import * as vsls from 'vsls/vscode'; + +import { ILiveShareApi } from '../../../common/application/types'; +import { IAsyncDisposable } from '../../../common/types'; +import { ClassType } from '../../../ioc/types'; + +// tslint:disable:no-any +export class RoleBasedFactory> { + private ctorArgs : any[]; + private firstTime : boolean = true; + private createPromise : Promise | undefined; + + constructor(private liveShare: ILiveShareApi, private noneCtor : CtorType, private hostCtor: CtorType, private guestCtor: CtorType, ...args: any[]) { + this.ctorArgs = args; + } + + public get() : Promise { + // Make sure only one create happens at a time + if (this.createPromise) { + return this.createPromise; + } + this.createPromise = this.createBasedOnRole(); + return this.createPromise; + } + + private async createBasedOnRole() : Promise { + + // Figure out our role to compute the object to create + const api = await this.liveShare.getApi(); + let ctor : CtorType = this.noneCtor; + + if (api) { + // Create based on role. + if (api.session && api.session.role === vsls.Role.Host) { + ctor = this.hostCtor; + } else if (api.session && api.session.role === vsls.Role.Guest) { + ctor = this.guestCtor; + } + } + + // Create our object + const obj = new ctor(...this.ctorArgs); + + // Rewrite the object's dispose so we can get rid of our own state. + const oldDispose = obj.dispose.bind(obj); + obj.dispose = () => { + this.createPromise = undefined; + return oldDispose(); + }; + + // If the session changes, also dispose + if (api && this.firstTime) { + this.firstTime = false; + api.onDidChangeSession((a) => obj.dispose()); + } + + return obj; + } +} diff --git a/src/client/datascience/jupyter/liveshare/types.ts b/src/client/datascience/jupyter/liveshare/types.ts new file mode 100644 index 000000000000..9a946a8a9bf6 --- /dev/null +++ b/src/client/datascience/jupyter/liveshare/types.ts @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +'use strict'; +import { ICell, InterruptResult } from '../../types'; + +// tslint:disable:max-classes-per-file + +export enum ServerResponseType { + ExecuteObservable, + Interrupt, + Restart, + Exception +} + +export interface IServerResponse { + type: ServerResponseType; + time: number; +} + +export interface IExecuteObservableResponse extends IServerResponse { + pos: number; + code: string; + id: string; // Unique id so guest side can tell what observable it belongs with + cells: ICell[] | undefined; +} + +export interface IInterruptResponse extends IServerResponse { + result: InterruptResult; +} + +export interface IRestartResponse extends IServerResponse { +} + +export interface IExceptionResponse extends IServerResponse { + message: string; +} + +// Map all responses to their properties +export interface IResponseMapping { + [ServerResponseType.ExecuteObservable]: IExecuteObservableResponse; + [ServerResponseType.Interrupt]: IInterruptResponse; + [ServerResponseType.Restart]: IRestartResponse; + [ServerResponseType.Exception]: IExceptionResponse; +} + +export interface ICatchupRequest { + since: number; +} diff --git a/src/client/datascience/jupyter/liveshare/utils.ts b/src/client/datascience/jupyter/liveshare/utils.ts new file mode 100644 index 000000000000..5c1dfb539ce8 --- /dev/null +++ b/src/client/datascience/jupyter/liveshare/utils.ts @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +'use strict'; +import { Disposable, Event } from 'vscode'; +import * as vsls from 'vsls/vscode'; + +import { createDeferred } from '../../../common/utils/async'; + +export async function waitForHostService(api: vsls.LiveShare, name: string) : Promise { + const service = await api.shareService(name); + if (service !== null && !service.isServiceAvailable) { + return waitForAvailability(service); + } + return service; +} + +export async function waitForGuestService(api: vsls.LiveShare, name: string) : Promise { + const service = await api.getSharedService(name); + if (service !== null && !service.isServiceAvailable) { + return waitForAvailability(service); + } + return service; +} + +interface IChangeWatchable { + readonly onDidChangeIsServiceAvailable: Event; +} + +async function waitForAvailability(service: T) : Promise { + const deferred = createDeferred(); + let disposable : Disposable | undefined; + try { + disposable = service.onDidChangeIsServiceAvailable(e => { + if (e) { + deferred.resolve(service); + } + }); + await deferred.promise; + } finally { + if (disposable) { + disposable.dispose(); + } + } + return service; +} diff --git a/src/client/datascience/liveshare/postOffice.ts b/src/client/datascience/liveshare/postOffice.ts new file mode 100644 index 000000000000..81a89547be2e --- /dev/null +++ b/src/client/datascience/liveshare/postOffice.ts @@ -0,0 +1,237 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +'use strict'; +import { JSONArray } from '@phosphor/coreutils'; +import * as vscode from 'vscode'; +import * as vsls from 'vsls/vscode'; + +import { ILiveShareApi } from '../../common/application/types'; +import { IAsyncDisposable } from '../../common/types'; +import { LiveShare, RegExpValues } from '../constants'; + +// tslint:disable:no-any + +interface IMessageArgs { + args: string; +} + +// This class is used to register two communication between a host and all of its guests +export class PostOffice implements IAsyncDisposable { + + private name: string; + private started : Promise; + private hostServer : vsls.SharedService | null = null; + private guestServer : vsls.SharedServiceProxy | null = null; + private currentRole : vsls.Role = vsls.Role.None; + private commandMap : { [key: string] : { thisArg: any; callback(...args: any[]) : void } } = {}; + + constructor(name: string, private liveShareApi: ILiveShareApi) { + this.name = name; + this.started = this.startCommandServer(); + + // Note to self, could the callbacks be keeping things alive that we don't want to be alive? + } + + public role = () => { + return this.currentRole; + } + + public async dispose() { + if (this.hostServer) { + const s = await this.started; + if (s !== null) { + await s.unshareService(this.name); + } + this.hostServer = null; + } + this.guestServer = null; + } + + public async postCommand(command: string, ...args: any[]) : Promise { + // Make sure startup finished + const api = await this.started; + let skipDefault = false; + + if (api && api.session) { + switch (this.currentRole) { + case vsls.Role.Guest: + // Ask host to broadcast + if (this.guestServer) { + this.guestServer.notify(LiveShare.LiveShareBroadcastRequest, this.createBroadcastArgs(command, ...args)); + } + skipDefault = true; + break; + case vsls.Role.Host: + // Notify everybody and call our local callback (by falling through) + if (this.hostServer) { + this.hostServer.notify(this.escapeCommandName(command), this.translateArgs(api, command, ...args)); + } + break; + default: + break; + } + } + + if (!skipDefault) { + // Default when not connected is to just call the registered callback + this.callCallback(command, ...args); + } + } + + public async registerCallback(command: string, callback: (...args: any[]) => void, thisArg?: any) : Promise { + const api = await this.started; + + // For a guest, make sure to register the notification + if (api && api.session && api.session.role === vsls.Role.Guest && this.guestServer) { + this.guestServer.onNotify(this.escapeCommandName(command), a => this.onGuestNotify(command, a as IMessageArgs)); + } + + // Always stick in the command map so that if we switch roles, we reregister + this.commandMap[command] = { callback, thisArg }; + + } + + private createBroadcastArgs(command: string, ...args: any[]) : IMessageArgs { + return { args: JSON.stringify([command, ...args]) }; + } + + private translateArgs(api: vsls.LiveShare, command: string, ...args: any[]) : IMessageArgs { + // Some file path args need to have their values translated to guest + // uri format for use on a guest. Try to find any file arguments + const callback = this.commandMap.hasOwnProperty(command) ? this.commandMap[command].callback : undefined; + if (callback) { + const str = callback.toString(); + + // Early check + if (str.includes('file')) { + const callbackArgs = str.match(RegExpValues.ParamsExractorRegEx); + if (callbackArgs && callbackArgs.length > 1) { + const argNames = callbackArgs[1].match(RegExpValues.ArgsSplitterRegEx); + if (argNames && argNames.length > 0) { + for (let i = 0; i < args.length; i += 1) { + if (argNames[i].includes('file')) { + const file = args[i]; + if (typeof file === 'string') { + args[i] = api.convertLocalUriToShared(vscode.Uri.file(file)).fsPath; + } + } + } + } + } + } + } + + // Make sure to eliminate all .toJSON functions on our arguments. Otherwise they're stringified incorrectly + for (let a = 0; a <= args.length; a += 1) { + // Eliminate this on only object types (https://stackoverflow.com/questions/8511281/check-if-a-value-is-an-object-in-javascript) + if (args[a] === Object(args[a])) { + args[a].toJSON = undefined; + } + } + + // Then wrap them all up in a string. + return { args: JSON.stringify(args) }; + } + + private escapeCommandName(command: string) : string { + // Replace . with $ instead. + return command.replace(/\./g, '$'); + } + + private unescapeCommandName(command: string) : string { + // Turn $ back into . + return command.replace(/\$/g, '.'); + } + + private onGuestNotify = (command: string, m: IMessageArgs) => { + const unescaped = this.unescapeCommandName(command); + const args = JSON.parse(m.args) as JSONArray; + this.callCallback(unescaped, ...args); + } + + private callCallback(command: string, ...args: any[]) { + const callback = this.getCallback(command); + if (callback) { + callback(...args); + } + } + + private getCallback(command: string) : ((...args: any[]) => void) | undefined { + let callback = this.commandMap.hasOwnProperty(command) ? this.commandMap[command].callback : undefined; + if (callback) { + // Bind the this arg if necessary + const thisArg = this.commandMap[command].thisArg; + if (thisArg) { + callback = callback.bind(thisArg); + } + } + + return callback; + } + + private async startCommandServer() : Promise { + const api = await this.liveShareApi.getApi(); + if (api !== null) { + api.onDidChangeSession(() => this.onChangeSession(api).ignoreErrors()); + await this.onChangeSession(api); + } + return api; + } + + private async onChangeSession(api: vsls.LiveShare) : Promise { + // Startup or shutdown our connection to the other side + if (api.session) { + if (this.currentRole !== api.session.role) { + // We're changing our role. + if (this.hostServer) { + await api.unshareService(this.name); + this.hostServer = null; + } + if (this.guestServer) { + this.guestServer = null; + } + } + + // Startup our proxy or server + this.currentRole = api.session.role; + if (api.session.role === vsls.Role.Host) { + this.hostServer = await api.shareService(this.name); + + // When we start the host, listen for the broadcast message + if (this.hostServer !== null) { + this.hostServer.onNotify(LiveShare.LiveShareBroadcastRequest, a => this.onBroadcastRequest(a as IMessageArgs)); + } + } else if (api.session.role === vsls.Role.Guest) { + this.guestServer = await api.getSharedService(this.name); + + // When we switch to guest mode, we may have to reregister all of our commands. + this.registerGuestCommands(api); + } + } + } + + private onBroadcastRequest = (a: IMessageArgs) => { + // This means we need to rebroadcast a request. We should also handle this request ourselves (as this means + // a guest is trying to tell everybody about a command) + if (a.args.length > 0) { + const jsonArray = JSON.parse(a.args) as JSONArray; + if (jsonArray !== null && jsonArray.length >= 2) { + const firstArg = jsonArray[0]; // More stupid hygiene problems. + const command = firstArg !== null ? firstArg.toString() : ''; + this.postCommand(command, jsonArray.slice(1)).ignoreErrors(); + } + } + } + + private registerGuestCommands(api: vsls.LiveShare) { + if (api && api.session && api.session.role === vsls.Role.Guest && this.guestServer !== null) { + const keys = Object.keys(this.commandMap); + keys.forEach(k => { + if (this.guestServer !== null) { // Hygiene is too dumb to recognize the if above + this.guestServer.onNotify(this.escapeCommandName(k), a => this.onGuestNotify(k, a as IMessageArgs)); + } + }); + } + } + +} diff --git a/src/client/datascience/serviceRegistry.ts b/src/client/datascience/serviceRegistry.ts index 52a3ecf03786..dbf5fe6276b4 100644 --- a/src/client/datascience/serviceRegistry.ts +++ b/src/client/datascience/serviceRegistry.ts @@ -3,6 +3,7 @@ 'use strict'; import { IServiceManager } from '../ioc/types'; import { CodeCssGenerator } from './codeCssGenerator'; +import { CommandBroker } from './commandBroker'; import { DataScience } from './datascience'; import { DataScienceCodeLensProvider } from './editor-integration/codelensprovider'; import { CodeWatcher } from './editor-integration/codewatcher'; @@ -10,15 +11,16 @@ import { History } from './history'; import { HistoryCommandListener } from './historycommandlistener'; import { HistoryProvider } from './historyProvider'; import { JupyterCommandFactory } from './jupyter/jupyterCommand'; -import { JupyterExecution } from './jupyter/jupyterExecution'; +import { JupyterExecution } from './jupyter/jupyterExecutionFactory'; import { JupyterExporter } from './jupyter/jupyterExporter'; import { JupyterImporter } from './jupyter/jupyterImporter'; -import { JupyterServer } from './jupyter/jupyterServer'; +import { JupyterServer } from './jupyter/jupyterServerFactory'; import { JupyterSessionManager } from './jupyter/jupyterSessionManager'; import { StatusProvider } from './statusProvider'; import { ICodeCssGenerator, ICodeWatcher, + ICommandBroker, IDataScience, IDataScienceCodeLensProvider, IDataScienceCommandListener, @@ -38,11 +40,12 @@ export function registerTypes(serviceManager: IServiceManager) { serviceManager.addSingleton(IDataScience, DataScience); serviceManager.addSingleton(IJupyterExecution, JupyterExecution); serviceManager.add(IDataScienceCommandListener, HistoryCommandListener); + serviceManager.addSingleton(ICommandBroker, CommandBroker); serviceManager.addSingleton(IHistoryProvider, HistoryProvider); serviceManager.add(IHistory, History); serviceManager.add(INotebookExporter, JupyterExporter); serviceManager.add(INotebookImporter, JupyterImporter); - serviceManager.add(INotebookServer, JupyterServer); + serviceManager.addSingleton(INotebookServer, JupyterServer); serviceManager.addSingleton(ICodeCssGenerator, CodeCssGenerator); serviceManager.addSingleton(IStatusProvider, StatusProvider); serviceManager.addSingleton(IJupyterSessionManager, JupyterSessionManager); diff --git a/src/client/datascience/types.ts b/src/client/datascience/types.ts index 36da08f4a2cd..68582510d29c 100644 --- a/src/client/datascience/types.ts +++ b/src/client/datascience/types.ts @@ -15,12 +15,13 @@ import { PythonInterpreter } from '../interpreter/contracts'; // Main interface export const IDataScience = Symbol('IDataScience'); export interface IDataScience extends Disposable { + activationStartTime: number; activate(): Promise; } export const IDataScienceCommandListener = Symbol('IDataScienceCommandListener'); export interface IDataScienceCommandListener { - register(commandManager: ICommandManager): void; + register(commandManager: ICommandBroker): void; } // Connection information for talking to a jupyter notebook process @@ -38,8 +39,7 @@ export enum InterruptResult { // Talks to a jupyter ipython kernel to retrieve data for cells export const INotebookServer = Symbol('INotebookServer'); -export interface INotebookServer extends Disposable { - onStatusChanged: Event; +export interface INotebookServer extends IAsyncDisposable { connect(conninfo: IConnection, kernelSpec: IJupyterKernelSpec | undefined, usingDarkTheme: boolean, cancelToken?: CancellationToken, workingDir?: string) : Promise; executeObservable(code: string, file: string, line: number, id?: string) : Observable; execute(code: string, file: string, line: number, cancelToken?: CancellationToken) : Promise; @@ -49,13 +49,15 @@ export interface INotebookServer extends Disposable { interruptKernel(timeoutInMs: number) : Promise; setInitialDirectory(directory: string): Promise; getConnectionInfo(): IConnection | undefined; + getSysInfo() : Promise; } export const IJupyterExecution = Symbol('IJupyterExecution'); -export interface IJupyterExecution { +export interface IJupyterExecution extends IAsyncDisposable { isNotebookSupported(cancelToken?: CancellationToken) : Promise; isImportSupported(cancelToken?: CancellationToken) : Promise; isKernelCreateSupported(cancelToken?: CancellationToken): Promise; + isKernelSpecSupported(cancelToken?: CancellationToken): Promise; connectToNotebookServer(uri: string | undefined, usingDarkTheme: boolean, useDefaultConfig: boolean, cancelToken?: CancellationToken, workingDir?: string) : Promise; spawnNotebook(file: string) : Promise; importNotebook(file: string, template: string) : Promise; @@ -211,3 +213,8 @@ export interface IDataScienceExtraSettings extends IDataScienceSettings { terminalCursor: string; }; } + +export const ICommandBroker = Symbol('ICommandBroker'); + +export interface ICommandBroker extends ICommandManager { +} diff --git a/src/datascience-ui/history-react/inputHistory.ts b/src/datascience-ui/history-react/inputHistory.ts index 4bfd8c5c914d..766e84137b6a 100644 --- a/src/datascience-ui/history-react/inputHistory.ts +++ b/src/datascience-ui/history-react/inputHistory.ts @@ -53,7 +53,7 @@ export class InputHistory { if (this.last === 0) { this.up = undefined; this.down = undefined; - } else { + } else if (this.last) { this.up = this.last + 1; this.down = this.last - 1; } diff --git a/src/test/datascience/dataScienceIocContainer.ts b/src/test/datascience/dataScienceIocContainer.ts index 15ec5e2616c8..69baddfbbaa0 100644 --- a/src/test/datascience/dataScienceIocContainer.ts +++ b/src/test/datascience/dataScienceIocContainer.ts @@ -21,6 +21,7 @@ import { IApplicationShell, ICommandManager, IDocumentManager, + ILiveShareApi, ITerminalManager, IWorkspaceService } from '../../client/common/application/types'; @@ -71,14 +72,15 @@ import { CodeCssGenerator } from '../../client/datascience/codeCssGenerator'; import { History } from '../../client/datascience/history'; import { HistoryProvider } from '../../client/datascience/historyProvider'; import { JupyterCommandFactory } from '../../client/datascience/jupyter/jupyterCommand'; -import { JupyterExecution } from '../../client/datascience/jupyter/jupyterExecution'; +import { JupyterExecution } from '../../client/datascience/jupyter/jupyterExecutionFactory'; import { JupyterExporter } from '../../client/datascience/jupyter/jupyterExporter'; import { JupyterImporter } from '../../client/datascience/jupyter/jupyterImporter'; -import { JupyterServer } from '../../client/datascience/jupyter/jupyterServer'; +import { JupyterServer } from '../../client/datascience/jupyter/jupyterServerFactory'; import { JupyterSessionManager } from '../../client/datascience/jupyter/jupyterSessionManager'; import { StatusProvider } from '../../client/datascience/statusProvider'; import { ICodeCssGenerator, + IDataScience, IHistory, IHistoryProvider, IJupyterCommandFactory, @@ -159,6 +161,7 @@ import { MockAutoSelectionService } from '../mocks/autoSelector'; import { UnitTestIocContainer } from '../unittests/serviceRegistry'; import { MockCommandManager } from './mockCommandManager'; import { MockJupyterManager } from './mockJupyterManager'; +import { MockLiveShareApi } from './mockLiveShare'; export class DataScienceIocContainer extends UnitTestIocContainer { @@ -199,6 +202,7 @@ export class DataScienceIocContainer extends UnitTestIocContainer { this.serviceManager.add(IHistory, History); this.serviceManager.add(INotebookImporter, JupyterImporter); this.serviceManager.add(INotebookExporter, JupyterExporter); + this.serviceManager.addSingleton(ILiveShareApi, MockLiveShareApi); this.serviceManager.add(INotebookServer, JupyterServer); this.serviceManager.add(IJupyterCommandFactory, JupyterCommandFactory); this.serviceManager.addSingleton(ICodeCssGenerator, CodeCssGenerator); @@ -237,6 +241,7 @@ export class DataScienceIocContainer extends UnitTestIocContainer { const workspaceService = TypeMoq.Mock.ofType(); const configurationService = TypeMoq.Mock.ofType(); const interpreterDisplay = TypeMoq.Mock.ofType(); + const datascience = TypeMoq.Mock.ofType(); // Setup default settings this.pythonSettings.datascience = { @@ -271,6 +276,8 @@ export class DataScienceIocContainer extends UnitTestIocContainer { workspaceService.setup(c => c.getConfiguration(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => workspaceConfig.object); workspaceService.setup(w => w.onDidChangeConfiguration).returns(() => this.configChangeEvent.event); interpreterDisplay.setup(i => i.refresh(TypeMoq.It.isAny())).returns(() => Promise.resolve()); + const startTime = Date.now(); + datascience.setup(d => d.activationStartTime).returns(() => startTime); class MockFileSystemWatcher implements FileSystemWatcher { public ignoreCreateEvents: boolean = false; @@ -337,6 +344,7 @@ export class DataScienceIocContainer extends UnitTestIocContainer { this.serviceManager.addSingletonInstance(IDocumentManager, documentManager.object); this.serviceManager.addSingletonInstance(IWorkspaceService, workspaceService.object); this.serviceManager.addSingletonInstance(IConfigurationService, configurationService.object); + this.serviceManager.addSingletonInstance(IDataScience, datascience.object); this.serviceManager.addSingleton(IBufferDecoder, BufferDecoder); this.serviceManager.addSingleton(IEnvironmentVariablesService, EnvironmentVariablesService); this.serviceManager.addSingletonInstance(IEnvironmentVariablesProvider, envVarsProvider.object); @@ -389,7 +397,7 @@ export class DataScienceIocContainer extends UnitTestIocContainer { dispose: () => { return; } }; - appShell.setup(a => a.showErrorMessage(TypeMoq.It.isAnyString())).returns(() => Promise.resolve('')); + appShell.setup(a => a.showErrorMessage(TypeMoq.It.isAnyString())).returns((e) => { throw e; }); appShell.setup(a => a.showInformationMessage(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve('')); appShell.setup(a => a.showSaveDialog(TypeMoq.It.isAny())).returns(() => Promise.resolve(Uri.file(''))); appShell.setup(a => a.setStatusBarMessage(TypeMoq.It.isAny())).returns(() => dummyDisposable); diff --git a/src/test/datascience/editor-integration/codelensprovider.unit.test.ts b/src/test/datascience/editor-integration/codelensprovider.unit.test.ts index ab990a14c32d..c2a515965cf7 100644 --- a/src/test/datascience/editor-integration/codelensprovider.unit.test.ts +++ b/src/test/datascience/editor-integration/codelensprovider.unit.test.ts @@ -4,6 +4,7 @@ import * as TypeMoq from 'typemoq'; import { CancellationTokenSource, TextDocument } from 'vscode'; +import { IDocumentManager } from '../../../client/common/application/types'; import { IConfigurationService, IDataScienceSettings, IPythonSettings } from '../../../client/common/types'; import { DataScienceCodeLensProvider } from '../../../client/datascience/editor-integration/codelensprovider'; import { ICodeWatcher, IDataScienceCodeLensProvider } from '../../../client/datascience/types'; @@ -15,12 +16,14 @@ suite('DataScienceCodeLensProvider Unit Tests', () => { let codeLensProvider: IDataScienceCodeLensProvider; let dataScienceSettings: TypeMoq.IMock; let pythonSettings: TypeMoq.IMock; + let documentManager: TypeMoq.IMock; let tokenSource : CancellationTokenSource; setup(() => { tokenSource = new CancellationTokenSource(); serviceContainer = TypeMoq.Mock.ofType(); configurationService = TypeMoq.Mock.ofType(); + documentManager = TypeMoq.Mock.ofType(); pythonSettings = TypeMoq.Mock.ofType(); dataScienceSettings = TypeMoq.Mock.ofType(); @@ -28,7 +31,7 @@ suite('DataScienceCodeLensProvider Unit Tests', () => { pythonSettings.setup(p => p.datascience).returns(() => dataScienceSettings.object); configurationService.setup(c => c.getSettings(TypeMoq.It.isAny())).returns(() => pythonSettings.object); - codeLensProvider = new DataScienceCodeLensProvider(serviceContainer.object, configurationService.object); + codeLensProvider = new DataScienceCodeLensProvider(serviceContainer.object, documentManager.object, configurationService.object); }); test('Initialize Code Lenses one document', () => { @@ -40,6 +43,7 @@ suite('DataScienceCodeLensProvider Unit Tests', () => { const targetCodeWatcher = TypeMoq.Mock.ofType(); targetCodeWatcher.setup(tc => tc.getCodeLenses()).returns(() => []).verifiable(TypeMoq.Times.once()); serviceContainer.setup(c => c.get(TypeMoq.It.isValue(ICodeWatcher))).returns(() => targetCodeWatcher.object).verifiable(TypeMoq.Times.once()); + documentManager.setup(d => d.textDocuments).returns(() => [document.object]); codeLensProvider.provideCodeLenses(document.object, tokenSource.token); @@ -58,6 +62,7 @@ suite('DataScienceCodeLensProvider Unit Tests', () => { targetCodeWatcher.setup(tc => tc.getFileName()).returns(() => 'test.py'); targetCodeWatcher.setup(tc => tc.getVersion()).returns(() => 1); serviceContainer.setup(c => c.get(TypeMoq.It.isValue(ICodeWatcher))).returns(() => targetCodeWatcher.object).verifiable(TypeMoq.Times.once()); + documentManager.setup(d => d.textDocuments).returns(() => [document.object]); codeLensProvider.provideCodeLenses(document.object, tokenSource.token); codeLensProvider.provideCodeLenses(document.object, tokenSource.token); @@ -86,6 +91,7 @@ suite('DataScienceCodeLensProvider Unit Tests', () => { targetCodeWatcher.setup(tc => tc.getFileName()).returns(() => 'test.py'); targetCodeWatcher.setup(tc => tc.getVersion()).returns(() => 1); serviceContainer.setup(c => c.get(TypeMoq.It.isValue(ICodeWatcher))).returns(() => targetCodeWatcher.object).verifiable(TypeMoq.Times.exactly(3)); + documentManager.setup(d => d.textDocuments).returns(() => [document.object, document2.object, document3.object]); codeLensProvider.provideCodeLenses(document.object, tokenSource.token); codeLensProvider.provideCodeLenses(document2.object, tokenSource.token); diff --git a/src/test/datascience/editor-integration/codewatcher.unit.test.ts b/src/test/datascience/editor-integration/codewatcher.unit.test.ts index 8fcd80ffc194..a6a4e67ec630 100644 --- a/src/test/datascience/editor-integration/codewatcher.unit.test.ts +++ b/src/test/datascience/editor-integration/codewatcher.unit.test.ts @@ -507,7 +507,8 @@ testing2`; // Command tests override getText, so just need the ranges here const version = 1; const inputText = '#%% foobar'; const document = createDocument(inputText, fileName, version, TypeMoq.Times.atLeastOnce()); - const codeLensProvider = new DataScienceCodeLensProvider(serviceContainer.object, configService.object); + documentManager.setup(d => d.textDocuments).returns(() => [document.object]); + const codeLensProvider = new DataScienceCodeLensProvider(serviceContainer.object, documentManager.object, configService.object); let result = codeLensProvider.provideCodeLenses(document.object, tokenSource.token); expect(result, 'result not okay').to.be.ok; diff --git a/src/test/datascience/execution.unit.test.ts b/src/test/datascience/execution.unit.test.ts index 5f34c0706f9b..069a24e6db2a 100644 --- a/src/test/datascience/execution.unit.test.ts +++ b/src/test/datascience/execution.unit.test.ts @@ -18,6 +18,7 @@ import { IWorkspaceService } from '../../client/common/application/types'; import { WorkspaceService } from '../../client/common/application/workspace'; import { PythonSettings } from '../../client/common/configSettings'; import { ConfigurationService } from '../../client/common/configuration/service'; +import { LiveShareApi } from '../../client/common/liveshare/liveshare'; import { Logger } from '../../client/common/logger'; import { FileSystem } from '../../client/common/platform/fileSystem'; import { IFileSystem, TemporaryFile } from '../../client/common/platform/types'; @@ -34,7 +35,7 @@ import { IAsyncDisposableRegistry, IConfigurationService, IDisposableRegistry, I import { Architecture } from '../../client/common/utils/platform'; import { EXTENSION_ROOT_DIR } from '../../client/constants'; import { JupyterCommandFactory } from '../../client/datascience/jupyter/jupyterCommand'; -import { JupyterExecution } from '../../client/datascience/jupyter/jupyterExecution'; +import { JupyterExecution } from '../../client/datascience/jupyter/jupyterExecutionFactory'; import { ICell, IConnection, IJupyterKernelSpec, INotebookServer, InterruptResult } from '../../client/datascience/types'; import { EnvironmentActivationService } from '../../client/interpreter/activation/service'; import { InterpreterType, PythonInterpreter } from '../../client/interpreter/contracts'; @@ -63,10 +64,6 @@ class MockJupyterServer implements INotebookServer { } return Promise.reject('invalid server startup'); } - //tslint:disable-next-line:no-any - public onStatusChanged(_listener: (e: boolean) => any, _thisArgs?: any, _disposables?: Disposable[]): Disposable { - return { dispose: noop }; - } public getCurrentState(): Promise { throw new Error('Method not implemented'); } @@ -95,6 +92,10 @@ class MockJupyterServer implements INotebookServer { return Promise.resolve(); } + public getSysInfo() : Promise { + return Promise.resolve(undefined); + } + public interruptKernel(timeout: number) : Promise { throw new Error('Method not implemented'); } @@ -142,6 +143,7 @@ class DisposableRegistry implements IDisposableRegistry, IAsyncDisposableRegistr suite('Jupyter Execution', async () => { const interpreterService = mock(InterpreterService); const executionFactory = mock(PythonExecutionFactory); + const liveShare = mock(LiveShareApi); const configService = mock(ConfigurationService); const processServiceFactory = mock(ProcessServiceFactory); const knownSearchPaths = mock(KnownSearchPathsForInterpreters); @@ -496,6 +498,8 @@ suite('Jupyter Execution', async () => { when(executionFactory.createActivatedEnvironment(argThat(o => !o || o.interpreter === activeInterpreter))).thenResolve(activeService); when(processServiceFactory.create()).thenResolve(processService.object); + when(liveShare.getApi()).thenResolve(null); + // Service container needs logger, file system, and config service when(serviceContainer.get(IConfigurationService)).thenReturn(instance(configService)); when(serviceContainer.get(IFileSystem)).thenReturn(instance(fileSystem)); @@ -521,7 +525,8 @@ suite('Jupyter Execution', async () => { maxOutputSize: 400, sendSelectionToInteractiveWindow: false, codeRegularExpression: '^(#\\s*%%|#\\s*\\|#\\s*In\\[\\d*?\\]|#\\s*In\\[ \\])', - markdownRegularExpression: '^(#\\s*%%\\s*\\[markdown\\]|#\\s*\\)' + markdownRegularExpression: '^(#\\s*%%\\s*\\[markdown\\]|#\\s*\\)', + allowLiveShare: false }; // Service container also needs to generate jupyter servers. However we can't use a mock as that messes up returning @@ -546,6 +551,7 @@ suite('Jupyter Execution', async () => { const mockSessionManager = new MockJupyterManager(instance(serviceManager)); return new JupyterExecution( + instance(liveShare), instance(executionFactory), instance(interpreterService), instance(processServiceFactory), diff --git a/src/test/datascience/history.functional.test.tsx b/src/test/datascience/history.functional.test.tsx index fd4e989c69c1..1abbb8c53de6 100644 --- a/src/test/datascience/history.functional.test.tsx +++ b/src/test/datascience/history.functional.test.tsx @@ -702,7 +702,7 @@ for _ in range(50): }; let exportCalled = false; const appShell = TypeMoq.Mock.ofType(); - appShell.setup(a => a.showErrorMessage(TypeMoq.It.isAnyString())).returns(() => Promise.resolve('')); + appShell.setup(a => a.showErrorMessage(TypeMoq.It.isAnyString())).returns((e) => { throw e; }); appShell.setup(a => a.showInformationMessage(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve('')); appShell.setup(a => a.showSaveDialog(TypeMoq.It.isAny())).returns(() => { exportCalled = true; diff --git a/src/test/datascience/historyCommandListener.unit.test.ts b/src/test/datascience/historyCommandListener.unit.test.ts index 45f8a0c1735c..10055c889a57 100644 --- a/src/test/datascience/historyCommandListener.unit.test.ts +++ b/src/test/datascience/historyCommandListener.unit.test.ts @@ -33,7 +33,7 @@ import { generateCells } from '../../client/datascience/cellFactory'; import { Commands } from '../../client/datascience/constants'; import { HistoryCommandListener } from '../../client/datascience/historycommandlistener'; import { HistoryProvider } from '../../client/datascience/historyProvider'; -import { JupyterExecution } from '../../client/datascience/jupyter/jupyterExecution'; +import { JupyterExecution } from '../../client/datascience/jupyter/jupyterExecutionFactory'; import { JupyterExporter } from '../../client/datascience/jupyter/jupyterExporter'; import { JupyterImporter } from '../../client/datascience/jupyter/jupyterImporter'; import { IHistory, INotebookServer, IStatusProvider } from '../../client/datascience/types'; diff --git a/src/test/datascience/mockLiveShare.ts b/src/test/datascience/mockLiveShare.ts new file mode 100644 index 000000000000..5db99989c34e --- /dev/null +++ b/src/test/datascience/mockLiveShare.ts @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +'use strict'; +import { injectable } from 'inversify'; +import * as vsls from 'vsls/vscode'; + +import { ILiveShareApi } from '../../client/common/application/types'; + +// tslint:disable:no-any unified-signatures + +@injectable() +export class MockLiveShareApi implements ILiveShareApi { + + public getApi(): Promise { + return Promise.resolve(null); + } +} diff --git a/src/test/datascience/notebook.functional.test.ts b/src/test/datascience/notebook.functional.test.ts index aa6ddde0b8ac..2a32bd2fab47 100644 --- a/src/test/datascience/notebook.functional.test.ts +++ b/src/test/datascience/notebook.functional.test.ts @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. 'use strict'; - -// tslint:disable:no-any no-multiline-string max-func-body-length no-console max-classes-per-file trailing-comma import { nbformat } from '@jupyterlab/coreutils'; import { assert } from 'chai'; import * as fs from 'fs-extra'; @@ -20,7 +18,8 @@ import { createDeferred } from '../../client/common/utils/async'; import { noop } from '../../client/common/utils/misc'; import { Architecture } from '../../client/common/utils/platform'; import { concatMultilineString } from '../../client/datascience/common'; -import { JupyterExecution } from '../../client/datascience/jupyter/jupyterExecution'; +import { JupyterExecution } from '../../client/datascience/jupyter/jupyterExecutionFactory'; +import { RoleBasedFactory } from '../../client/datascience/jupyter/liveshare/roleBasedFactory'; import { CellState, ICell, @@ -38,6 +37,7 @@ import { InterpreterType, PythonInterpreter } from '../../client/interpreter/contracts'; +import { ClassType } from '../../client/ioc/types'; import { ICellViewModel } from '../../datascience-ui/history-react/cell'; import { generateTestState } from '../../datascience-ui/history-react/mainPanelState'; import { sleep } from '../core'; @@ -45,6 +45,7 @@ import { DataScienceIocContainer } from './dataScienceIocContainer'; import { SupportedCommands } from './mockJupyterManager'; import { MockJupyterSession } from './mockJupyterSession'; +// tslint:disable:no-any no-multiline-string max-func-body-length no-console max-classes-per-file trailing-comma suite('Jupyter notebook tests', () => { const disposables: Disposable[] = []; let jupyterExecution: IJupyterExecution; @@ -175,18 +176,11 @@ suite('Jupyter notebook tests', () => { // Test all mime types together so we don't have to startup and shutdown between // each const server = await createNotebookServer(true); - let statusCount: number = 0; if (server) { - server.onStatusChanged((bool: boolean) => { - statusCount += 1; - }); for (let i = 0; i < types.length; i += 1) { - ioc.getSettings().datascience.markdownRegularExpression = types[i].markdownRegEx; - const prevCount = statusCount; + const markdownRegex = types[i].markdownRegEx ? types[i].markdownRegEx : ''; + ioc.getSettings().datascience.markdownRegularExpression = markdownRegex!; await verifyCell(server, i, types[i].code, types[i].mimeType, types[i].cellType, types[i].verifyValue); - if (types[i].cellType !== 'markdown') { - assert.ok(statusCount > prevCount, 'Status didnt update'); - } } } }); @@ -208,7 +202,7 @@ suite('Jupyter notebook tests', () => { // Catch exceptions. Throw a specific assertion if the promise fails try { const testDir = path.join(EXTENSION_ROOT_DIR, 'src', 'test', 'datascience'); - const server = await jupyterExecution.connectToNotebookServer(undefined, useDarkTheme, useDefaultConfig, undefined, testDir); + const server = await jupyterExecution.connectToNotebookServer(undefined, useDarkTheme ? true : false, useDefaultConfig, undefined, testDir); if (expectFailure) { assert.ok(false, `Expected server to not be created`); } @@ -771,23 +765,33 @@ plt.show()`, } }); + async function getNotebookSession(server: INotebookServer | undefined) : Promise { + if (server) { + // This is kinda fragile. It reliese on impl details to get to the session. Might + // just expose it? + const innerServerFactory = (server as any)['serverFactory'] as RoleBasedFactory>; + const innerServer = await innerServerFactory.get(); + assert.ok(innerServer, 'Cannot find the inner server'); + return (innerServer as any)['session'] as MockJupyterSession; + } + } + runTest('Theme modifies execution', async () => { if (ioc.mockJupyter) { let server = await createNotebookServer(true, false, false); - let session = (server as any)['session'] as MockJupyterSession; - + let session = await getNotebookSession(server); const light = '%matplotlib inline\nimport matplotlib.pyplot as plt'; const dark = '%matplotlib inline\nimport matplotlib.pyplot as plt\nfrom matplotlib import style\nstyle.use(\'dark_background\')'; - assert.ok(session.getExecutes().indexOf(light) >= 0, 'light not found'); - assert.ok(session.getExecutes().indexOf(dark) < 0, 'dark found when not allowed'); - await server.dispose(); + assert.ok(session!.getExecutes().indexOf(light) >= 0, 'light not found'); + assert.ok(session!.getExecutes().indexOf(dark) < 0, 'dark found when not allowed'); + await server!.dispose(); server = await createNotebookServer(true, false, true); - session = (server as any)['session'] as MockJupyterSession; - assert.ok(session.getExecutes().indexOf(dark) >= 0, 'dark not found'); - assert.ok(session.getExecutes().indexOf(light) < 0, 'light found when not allowed'); - await server.dispose(); + session = await getNotebookSession(server); + assert.ok(session!.getExecutes().indexOf(dark) >= 0, 'dark not found'); + assert.ok(session!.getExecutes().indexOf(light) < 0, 'light found when not allowed'); + await server!.dispose(); } }); diff --git a/src/test/unittests/serviceRegistry.ts b/src/test/unittests/serviceRegistry.ts index 1dee35313ad5..bc6dcc02c860 100644 --- a/src/test/unittests/serviceRegistry.ts +++ b/src/test/unittests/serviceRegistry.ts @@ -7,9 +7,9 @@ import { IProcessServiceFactory } from '../../client/common/process/types'; import { CodeCssGenerator } from '../../client/datascience/codeCssGenerator'; import { History } from '../../client/datascience/history'; import { HistoryProvider } from '../../client/datascience/historyProvider'; -import { JupyterExecution } from '../../client/datascience/jupyter/jupyterExecution'; +import { JupyterExecution } from '../../client/datascience/jupyter/jupyterExecutionFactory'; import { JupyterImporter } from '../../client/datascience/jupyter/jupyterImporter'; -import { JupyterServer } from '../../client/datascience/jupyter/jupyterServer'; +import { JupyterServer } from '../../client/datascience/jupyter/jupyterServerFactory'; import { ICodeCssGenerator, IHistory, diff --git a/tsconfig.datascience-ui.json b/tsconfig.datascience-ui.json index d6cab4861f5e..579de604c412 100644 --- a/tsconfig.datascience-ui.json +++ b/tsconfig.datascience-ui.json @@ -9,7 +9,10 @@ ], "jsx": "react", "sourceMap": true, - "rootDir": "src", + "rootDirs": [ + "node_modules/vsls", + "src" + ], "experimentalDecorators": true, "allowSyntheticDefaultImports": true, "noImplicitThis": false,