diff --git a/src/api.internal.d.ts b/src/api.internal.d.ts new file mode 100644 index 00000000000..96a8d837f95 --- /dev/null +++ b/src/api.internal.d.ts @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// The following is required to make sure the types are merged correctly. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { CancellationToken } from 'vscode'; + +// These types are only used internally within the extension. +// Never to be exposed to other extensions. +// Could also contain proposed API that is used internally and not exposed to other extensions. + +declare module './api' { + export interface JupyterServer { + /** + * Display a `trash` icon next to each server in the quick pick. + * Allowing the user to remove this server. + * Currently used only by the Jupyter Extension. + * A better more generic way to deal with this would be via commands. + */ + remove?(): Promise; + } + export interface JupyterServerCollection { + /** + * Internally used by Jupyter extension to track the extension that created this server. + */ + readonly extensionId: string; + } + + export interface IJupyterUriProvider { + getServerUriWithoutAuthInfo?(handle: string): Promise; + } +} diff --git a/src/api.proposed.d.ts b/src/api.proposed.d.ts index 4ae69672ed7..91b735c3f46 100644 --- a/src/api.proposed.d.ts +++ b/src/api.proposed.d.ts @@ -1,4 +1,136 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -// +// API & types defined in this file are proposed and subject to change. +// To use these types please reach out to the Jupyter Extension team (file an issue on the Jupyter Extension GitHub repo). +// Or please wait for these to be finalized and released. + +import { CancellationToken } from 'vscode'; +import { Command, Event, Uri } from 'vscode'; + +declare module './api' { + /** + * Provides information required to connect to a Jupyter Server. + */ + export interface JupyterServerConnectionInformation { + /** + * Base Url of the Jupyter Server. + * E.g. http://localhost:8888 or http://remoteServer.com/hub/user/, etc. + */ + readonly baseUrl: Uri; + /** + * Jupyter auth Token. + */ + readonly token: string; + /** + * Authorization header to be used when connecting to the server. + */ + readonly authorizationHeader?: Record; + /** + * The local directory that maps to the remote directory of the Jupyter Server. + * E.g. assume you start Jupyter Notebook with --notebook-dir=/foo/bar, + * and you have a file named /foo/bar/sample.ipynb, /foo/bar/sample2.ipynb and the like. + * Then assume the mapped local directory will be /users/xyz/remoteServer and the files sample.ipynb and sample2.ipynb + * are in the above local directory. + * + * Using this setting one can map the local directory to the remote directory. + * In this case the value of this property would be /users/xyz/remoteServer. + * + * Note: A side effect of providing this value is the session names are generated the way they are in Jupyter Notebook/Lab. + * I.e. the session names map to the relative path of the notebook file. + * As a result when attempting to create a new session for a notebook/file, Jupyter will + * first check if a session already exists for the same file and same kernel, and if so, will re-use that session. + */ + readonly mappedRemoteNotebookDir?: Uri; + /** + * Returns the sub-protocols to be used. See details of `protocols` here https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket + * Useful if there is a custom authentication scheme that needs to be used for WebSocket connections. + * Note: The client side npm package @jupyterlab/services uses WebSockets to connect to remote Kernels. + */ + readonly webSocketProtocols?: string[]; + } + + /** + * Represents a Jupyter Server displayed in the list of Servers. + */ + export interface JupyterServer { + /** + * Unique identifier for this server. + */ + readonly id: string; + /** + * A human-readable string representing the name of the Server. This can be read and updated by the extension. + */ + label: string; + /** + * Returns the connection information for this server. + */ + resolveConnectionInformation(token: CancellationToken): Promise; + } + + /** + * Provider of Jupyter Servers. + */ + export interface JupyterServerProvider { + /** + * Event fired when the list of servers changes. + */ + onDidChangeServers: Event; + /** + * Returns the list of servers. + */ + getJupyterServers(token: CancellationToken): Promise; + } + /** + * Provider of Jupyter Server Commands. + * Each command allows the user to perform an action. + * The return value of the command should be of the form Promise + * The returned value have the following meaning: + * - JupyterServer : The Jupyter Server object that was created + * - 'back' : Go back to the previous screen + * - undefined|void : Do nothing + */ + export interface JupyterServerCommandProvider { + /** + * Default command to be used when there are no servers. This can be read and updated by the extension. + * If not set, and there are not servers, then the user will be prompted to select a command from a list of commands returned by `getCommands`. + */ + selected?: Command; + /** + * Returns a list of commands to be displayed to the user. + */ + getCommands(token: CancellationToken): Promise; + } + export interface JupyterServerCollection { + /** + * Unique identifier of the Server Collection. + */ + readonly id: string; + /** + * A human-readable string representing the collection of the Servers. This can be read and updated by the extension. + */ + label: string; + /** + * A link to a resource containing more information. This can be read and updated by the extension. + */ + documentation?: Uri; + /** + * Provider of Jupyter Servers. This can be read and updated by the extension. + */ + serverProvider?: JupyterServerProvider; + /** + * Provider of Commands. This can be read and updated by the extension. + */ + commandProvider?: JupyterServerCommandProvider; + /** + * Removes this Server Collection. + */ + dispose(): void; + } + export interface JupyterAPI { + /** + * Creates a Jupyter Server Collection that can be displayed in the Notebook Kernel Picker. + */ + createJupyterServerCollection(id: string, label: string): Promise; + } +} diff --git a/src/extension.node.ts b/src/extension.node.ts index 06df3dd9523..9fca2b34ede 100644 --- a/src/extension.node.ts +++ b/src/extension.node.ts @@ -136,7 +136,8 @@ export async function activate(context: IExtensionContext): Promise Promise.resolve(undefined), getSuggestedController: () => Promise.resolve(undefined), addRemoteJupyterServer: () => Promise.resolve(undefined), - openNotebook: () => Promise.reject() + openNotebook: () => Promise.reject(), + createJupyterServerCollection: () => Promise.reject() }; } } diff --git a/src/extension.web.ts b/src/extension.web.ts index 9f00c48a5a7..4feb9454e27 100644 --- a/src/extension.web.ts +++ b/src/extension.web.ts @@ -141,7 +141,8 @@ export async function activate(context: IExtensionContext): Promise Promise.resolve(undefined), getSuggestedController: () => Promise.resolve(undefined), addRemoteJupyterServer: () => Promise.resolve(undefined), - openNotebook: () => Promise.reject() + openNotebook: () => Promise.reject(), + createJupyterServerCollection: () => Promise.reject() }; } } diff --git a/src/kernels/jupyter/connection/jupyterServerProviderRegistry.ts b/src/kernels/jupyter/connection/jupyterServerProviderRegistry.ts new file mode 100644 index 00000000000..9a784960e0f --- /dev/null +++ b/src/kernels/jupyter/connection/jupyterServerProviderRegistry.ts @@ -0,0 +1,266 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { CancellationTokenSource, EventEmitter, QuickPickItem, Uri, commands } from 'vscode'; +import { + IJupyterServerUri, + IJupyterUriProvider, + JupyterServer, + JupyterServerCollection, + JupyterServerCommandProvider, + JupyterServerProvider +} from '../../../api'; +import { Disposables } from '../../../platform/common/utils'; +import { IJupyterServerProviderRegistry, IJupyterUriProviderRegistration } from '../types'; +import { IDisposable, IDisposableRegistry } from '../../../platform/common/types'; +import { inject } from 'inversify'; +import { disposeAllDisposables } from '../../../platform/common/helpers'; +import { traceError } from '../../../platform/logging'; +import { JVSC_EXTENSION_ID } from '../../../platform/common/constants'; + +class JupyterServerCollectionImpl extends Disposables implements JupyterServerCollection { + private _serverProvider?: JupyterServerProvider; + private _commandProvider?: JupyterServerCommandProvider; + documentation?: Uri | undefined; + private _onDidChangeProvider = new EventEmitter(); + onDidChangeProvider = this._onDidChangeProvider.event; + set serverProvider(value: JupyterServerProvider | undefined) { + this._serverProvider = value; + this._onDidChangeProvider.fire(); + } + get serverProvider(): JupyterServerProvider | undefined { + return this._serverProvider; + } + set commandProvider(value: JupyterServerCommandProvider | undefined) { + this._commandProvider = value; + this._onDidChangeProvider.fire(); + } + get commandProvider(): JupyterServerCommandProvider | undefined { + return this._commandProvider; + } + + constructor( + public readonly extensionId: string, + public readonly id: string, + public label: string + ) { + super(); + } +} + +class JupyterUriProviderAdaptor extends Disposables implements IJupyterUriProvider { + readonly id: string; + public get displayName() { + return this.provider.label; + } + detail?: string | undefined; + private _onDidChangeHandles = new EventEmitter(); + onDidChangeHandles = this._onDidChangeHandles.event; + private providerChanges: IDisposable[] = []; + removeHandle?(handle: string): Promise; + getServerUriWithoutAuthInfo?(handle: string): Promise; + constructor(private readonly provider: JupyterServerCollection) { + super(); + this.id = provider.id; + this.hookupProviders(); + + // Only jupyter extension supports the `remoteHandle` API. + if (this.provider.extensionId === JVSC_EXTENSION_ID) { + this.removeHandle = this.removeHandleImpl.bind(this); + this.getServerUriWithoutAuthInfo = this.getServerUriWithoutAuthInfoImpl.bind(this); + } + } + override dispose() { + super.dispose(); + disposeAllDisposables(this.providerChanges); + } + private hookupProviders() { + disposeAllDisposables(this.providerChanges); + if (this.provider.serverProvider) { + this.provider.serverProvider.onDidChangeServers( + () => this._onDidChangeHandles.fire(), + this, + this.providerChanges + ); + } + } + async getQuickPickEntryItems(): Promise<(QuickPickItem & { default?: boolean | undefined })[]> { + if (!this.provider.commandProvider) { + throw new Error(`No Jupyter Server Command Provider for ${this.provider.extensionId}#${this.provider.id}`); + } + const token = new CancellationTokenSource(); + try { + const items = await this.provider.commandProvider.getCommands(token.token); + const selectedCommand = items.find((c) => c.title === this.provider.commandProvider?.selected?.title); + return items.map((c) => { + return { + label: c.title, + tooltip: c.tooltip, + default: c === selectedCommand + }; + }); + } catch (ex) { + traceError( + `Failed to get Jupyter Server Commands from ${this.provider.extensionId}#${this.provider.id}`, + ex + ); + return []; + } finally { + token.dispose(); + } + } + async handleQuickPick(item: QuickPickItem, _backEnabled: boolean): Promise { + if (!this.provider.commandProvider) { + throw new Error(`No Jupyter Server Command Provider for ${this.provider.extensionId}#${this.provider.id}`); + } + const token = new CancellationTokenSource(); + try { + const items = await this.provider.commandProvider.getCommands(token.token); + const command = items.find((c) => c.title === item.label); + if (!command) { + throw new Error( + `Jupyter Server Command ${item.label} not found in Command Provider ${this.provider.extensionId}#${this.provider.id}` + ); + } + try { + const result: JupyterServer | 'back' | undefined = await commands.executeCommand( + command.command, + ...(command.arguments || []) + ); + if (result === 'back') { + return result; + } + return result?.id; + } catch (ex) { + traceError( + `Failed to execute Jupyter Server Command ${item.label} in Command Provider ${this.provider.extensionId}#${this.provider.id}`, + ex + ); + } + } finally { + token.dispose(); + } + } + async getServerUri(handle: string): Promise { + if (!this.provider.serverProvider) { + throw new Error(`No Jupyter Server Provider for ${this.provider.extensionId}#${this.provider.id}`); + } + const token = new CancellationTokenSource(); + try { + const servers = await this.provider.serverProvider.getJupyterServers(token.token); + const server = servers.find((s) => s.id === handle); + if (!server) { + throw new Error( + `Jupyter Server ${handle} not found in Provider ${this.provider.extensionId}#${this.provider.id}` + ); + } + const info = await server.resolveConnectionInformation(token.token); + return { + baseUrl: info.baseUrl.toString(), + displayName: server.label, + token: info.token, + authorizationHeader: info.authorizationHeader, + mappedRemoteNotebookDir: info.mappedRemoteNotebookDir?.toString(), + webSocketProtocols: info.webSocketProtocols + }; + } finally { + token.dispose(); + } + } + async getHandles(): Promise { + if (this.provider.serverProvider) { + const token = new CancellationTokenSource(); + try { + const servers = await this.provider.serverProvider.getJupyterServers(token.token); + return servers.map((s) => s.id); + } catch (ex) { + traceError(`Failed to get Jupyter Servers from ${this.provider.extensionId}#${this.provider.id}`, ex); + return []; + } finally { + token.dispose(); + } + } else { + return []; + } + } + async getServerUriWithoutAuthInfoImpl(handle: string): Promise { + if (!this.provider.serverProvider) { + throw new Error(`No Jupyter Server Provider for ${this.provider.extensionId}#${this.provider.id}`); + } + const token = new CancellationTokenSource(); + try { + const servers = await this.provider.serverProvider.getJupyterServers(token.token); + const server = servers.find((s) => s.id === handle); + if (!server) { + throw new Error( + `Jupyter Server ${handle} not found in Provider ${this.provider.extensionId}#${this.provider.id}` + ); + } + return { + baseUrl: '', + token: '', + displayName: server.label + }; + } finally { + token.dispose(); + } + } + async removeHandleImpl(handle: string): Promise { + if (!this.provider.serverProvider) { + throw new Error(`No Jupyter Server Provider for ${this.provider.extensionId}#${this.provider.id}`); + } + const token = new CancellationTokenSource(); + try { + const servers = await this.provider.serverProvider.getJupyterServers(token.token); + const server = servers.find((s) => s.id === handle); + if (server?.remove) { + await server.remove(); + } + } finally { + token.dispose(); + } + } +} +export class JupyterServerProviderRegistry extends Disposables implements IJupyterServerProviderRegistry { + private readonly _onDidChangeProviders = new EventEmitter(); + public get onDidChangeProviders() { + return this._onDidChangeProviders.event; + } + private readonly _serverProviders = new Map(); + public get providers(): readonly JupyterServerCollection[] { + return Array.from(this._serverProviders.values()); + } + constructor( + @inject(IDisposableRegistry) disposables: IDisposableRegistry, + @inject(IJupyterUriProviderRegistration) + private readonly jupyterUriProviderRegistration: IJupyterUriProviderRegistration + ) { + super(); + disposables.push(this); + } + createJupyterServerCollection(extensionId: string, id: string, label: string): JupyterServerCollection { + const extId = `${extensionId}#${id}`; + if (this._serverProviders.has(extId)) { + throw new Error(`Jupyter Server Provider with id ${extId} already exists`); + } + const serverProvider = new JupyterServerCollectionImpl(extensionId, id, label); + this._serverProviders.set(extId, serverProvider); + const uriRegistration = this.jupyterUriProviderRegistration.registerProvider( + new JupyterUriProviderAdaptor(serverProvider), + extensionId + ); + + this._onDidChangeProviders.fire(); + serverProvider.onDidDispose( + () => { + uriRegistration.dispose(); + this._serverProviders.delete(extId); + this._onDidChangeProviders.fire(); + }, + this, + this.disposables + ); + + return serverProvider; + } +} diff --git a/src/kernels/jupyter/connection/jupyterUriProviderRegistration.ts b/src/kernels/jupyter/connection/jupyterUriProviderRegistration.ts index 5b318c3016a..51b7ffc8303 100644 --- a/src/kernels/jupyter/connection/jupyterUriProviderRegistration.ts +++ b/src/kernels/jupyter/connection/jupyterUriProviderRegistration.ts @@ -8,7 +8,6 @@ import { GLOBAL_MEMENTO, IDisposableRegistry, IExtensions, IMemento } from '../. import { swallowExceptions } from '../../../platform/common/utils/decorators'; import * as localize from '../../../platform/common/utils/localize'; import { noop } from '../../../platform/common/utils/misc'; -import { InvalidRemoteJupyterServerUriHandleError } from '../../errors/invalidRemoteJupyterServerUriHandleError'; import { IInternalJupyterUriProvider, IJupyterServerUriEntry, @@ -107,12 +106,6 @@ export class JupyterUriProviderRegistration `${localize.DataScience.unknownServerUri}. Provider Id=${id} and handle=${providerHandle.handle}` ); } - if (provider.getHandles) { - const handles = await provider.getHandles(); - if (!handles.includes(providerHandle.handle)) { - throw new InvalidRemoteJupyterServerUriHandleError(providerHandle); - } - } return provider.getServerUri(providerHandle.handle, doNotPromptForAuthInfo); } private async loadExtension(extensionId: string, providerId: string) { diff --git a/src/kernels/jupyter/types.ts b/src/kernels/jupyter/types.ts index 275549131af..84fbee4e1ef 100644 --- a/src/kernels/jupyter/types.ts +++ b/src/kernels/jupyter/types.ts @@ -25,7 +25,7 @@ import { } from '../types'; import { ClassType } from '../../platform/ioc/types'; import { ContributedKernelFinderKind, IContributedKernelFinder } from '../internalTypes'; -import { IJupyterServerUri, IJupyterUriProvider } from '../../api'; +import { IJupyterServerUri, IJupyterUriProvider, JupyterServerCollection } from '../../api'; export type JupyterServerInfo = { base_url: string; @@ -143,7 +143,6 @@ export interface IJupyterServerProvider { export interface IInternalJupyterUriProvider extends IJupyterUriProvider { readonly extensionId: string; - getServerUriWithoutAuthInfo?(handle: string): Promise; } export type JupyterServerProviderHandle = { /** @@ -290,3 +289,10 @@ export interface IRemoteKernelFinder extends IContributedKernelFinder; + providers: readonly JupyterServerCollection[]; + createJupyterServerCollection(extensionId: string, id: string, label: string): JupyterServerCollection; +} diff --git a/src/platform/common/helpers.ts b/src/platform/common/helpers.ts index 82566ba5e6d..297a7b48bbb 100644 --- a/src/platform/common/helpers.ts +++ b/src/platform/common/helpers.ts @@ -77,3 +77,24 @@ export function disposeAllDisposables(disposables: IDisposable[] = []) { export function format(value: string, ...args: string[]) { return value.replace(/{(\d+)}/g, (match, number) => (args[number] === undefined ? match : args[number])); } + +export function createPublicAPIProxy(target: T, membersToHide: (keyof T)[]): T { + const membersToHideList = membersToHide as (string | symbol)[]; + return new Proxy(target, { + has(target, p) { + if (membersToHideList.includes(p)) { + return false; + } + return Reflect.has(target, p); + }, + ownKeys(target) { + return Reflect.ownKeys(target).filter((key) => !membersToHideList.includes(key)); + }, + getOwnPropertyDescriptor(target, p) { + if (membersToHideList.includes(p)) { + return undefined; + } + return Reflect.getOwnPropertyDescriptor(target, p); + } + }); +} diff --git a/src/standalone/api/api.ts b/src/standalone/api/api.ts index 636b8c83e8e..4a20ce50725 100644 --- a/src/standalone/api/api.ts +++ b/src/standalone/api/api.ts @@ -3,7 +3,7 @@ import { ExtensionMode, NotebookDocument, Uri, commands, window, workspace } from 'vscode'; import { JupyterServerSelector } from '../../kernels/jupyter/connection/serverSelector'; -import { IJupyterUriProviderRegistration } from '../../kernels/jupyter/types'; +import { IJupyterServerProviderRegistry, IJupyterUriProviderRegistration } from '../../kernels/jupyter/types'; import { IDataViewerDataProvider, IDataViewerFactory } from '../../webviews/extension-side/dataviewer/types'; import { IPythonApiProvider, PythonApi } from '../../platform/api/types'; import { isTestExecution, JVSC_EXTENSION_ID, Telemetry } from '../../platform/common/constants'; @@ -15,6 +15,8 @@ import { sendTelemetryEvent } from '../../telemetry'; import { noop } from '../../platform/common/utils/misc'; import { isRemoteConnection } from '../../kernels/types'; import { JupyterAPI, IExportedKernelService, IJupyterUriProvider } from '../../api'; +import { createPublicAPIProxy } from '../../platform/common/helpers'; +import { Disposables } from '../../platform/common/utils'; export const IExportedKernelServiceFactory = Symbol('IExportedKernelServiceFactory'); export interface IExportedKernelServiceFactory { @@ -154,6 +156,19 @@ export function buildApi( extension: JVSC_EXTENSION_ID }); return notebookEditor.notebook; + }, + createJupyterServerCollection: async (id, label) => { + throw new Error('Not implemented'); + sendApiUsageTelemetry(extensions, 'createJupyterServerCollection'); + const extensionId = (await extensions.determineExtensionFromCallStack()).extensionId; + const registration = serviceContainer.get(IJupyterServerProviderRegistry); + const collection = registration.createJupyterServerCollection(id, label, extensionId); + // Do not expose unwanted properties to the extensions. + return createPublicAPIProxy(collection as unknown as typeof collection & Disposables, [ + 'disposables', + 'isDisposed', + 'dispose' + ]); } };