Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Export to html/pdf/python on Web when connected to remote server #10069

Merged
merged 13 commits into from
May 20, 2022
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -346,25 +346,25 @@
"command": "jupyter.export",
"title": "%DataScience.notebookExportAs%",
"category": "Jupyter",
"enablement": "isWorkspaceTrusted && !jupyter.webExtension"
"enablement": "isWorkspaceTrusted && !jupyter.webExtension || isWorkspaceTrusted && jupyter.webExtension && notebookKernel =~ /^ms-toolsai.jupyter\\//"
},
{
"command": "jupyter.exportAsPythonScript",
"title": "%jupyter.command.jupyter.exportAsPythonScript.title%",
"category": "Jupyter",
"enablement": "isWorkspaceTrusted && !jupyter.webExtension"
"enablement": "isWorkspaceTrusted && !jupyter.webExtension || isWorkspaceTrusted && jupyter.webExtension && notebookKernel =~ /^ms-toolsai.jupyter\\//"
},
{
"command": "jupyter.exportToHTML",
"title": "%jupyter.command.jupyter.exportToHTML.title%",
"category": "Jupyter",
"enablement": "isWorkspaceTrusted && !jupyter.webExtension"
"enablement": "isWorkspaceTrusted && !jupyter.webExtension || isWorkspaceTrusted && jupyter.webExtension && notebookKernel =~ /^ms-toolsai.jupyter\\//"
},
{
"command": "jupyter.exportToPDF",
"title": "%jupyter.command.jupyter.exportToPDF.title%",
"category": "Jupyter",
"enablement": "isWorkspaceTrusted && !jupyter.webExtension"
"enablement": "isWorkspaceTrusted && !jupyter.webExtension || isWorkspaceTrusted && jupyter.webExtension && notebookKernel =~ /^ms-toolsai.jupyter\\//"
},
{
"command": "jupyter.selectJupyterInterpreter",
Expand Down
11 changes: 10 additions & 1 deletion src/kernels/common/baseJupyterSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,13 @@ import { JupyterInvalidKernelError } from '../../platform/errors/jupyterInvalidK
import { JupyterWaitForIdleError } from '../../platform/errors/jupyterWaitForIdleError';
import { KernelInterruptTimeoutError } from '../../platform/errors/kernelInterruptTimeoutError';
import { SessionDisposedError } from '../../platform/errors/sessionDisposedError';
import { IJupyterSession, ISessionWithSocket, KernelConnectionMetadata, KernelSocketInformation } from '../types';
import {
IJupyterServerSession,
IJupyterSession,
ISessionWithSocket,
KernelConnectionMetadata,
KernelSocketInformation
} from '../types';
import { ChainingExecuteRequester } from './chainingExecuteRequester';
import { getResourceType } from '../../platform/common/utils';
import { KernelProgressReporter } from '../../platform/progress/kernelProgressReporter';
Expand Down Expand Up @@ -127,6 +133,9 @@ export abstract class BaseJupyterSession implements IJupyterSession {
traceInfo(`Unhandled message found: ${m.header.msg_type}`);
};
}
public isServerSession(): this is IJupyterServerSession {
return false;
}
public async dispose(): Promise<void> {
await this.shutdownImplementation(false);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { JupyterInstallError } from '../../../platform/errors/jupyterInstallErro
import { ProductNames } from '../../installer/productNames';
import { Product, IInstaller, InstallerResponse } from '../../installer/types';
import { HelpLinks } from '../../../platform/common/constants';
import { reportAction } from '../../../platform/progress/decorator.node';
import { reportAction } from '../../../platform/progress/decorator';
import { ReportableAction } from '../../../platform/progress/types';
import { JupyterInterpreterDependencyResponse } from '../types';
import { IJupyterCommandFactory } from '../types.node';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { inject, injectable } from 'inversify';
import { CancellationToken, Uri } from 'vscode';
import { traceError } from '../../../platform/logging';
import { IPythonExecutionFactory, IPythonDaemonExecutionService } from '../../../platform/common/process/types.node';
import { reportAction } from '../../../platform/progress/decorator.node';
import { reportAction } from '../../../platform/progress/decorator';
import { ReportableAction } from '../../../platform/progress/types';
import { PythonEnvironment } from '../../../platform/pythonEnvironments/info';
import { JupyterDaemonModule } from '../../../webviews/webview-side/common/constants';
Expand Down
73 changes: 68 additions & 5 deletions src/kernels/jupyter/session/jupyterSession.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
'use strict';
import type { ContentsManager, Kernel, KernelSpecManager, Session, SessionManager } from '@jupyterlab/services';
import type {
Contents,
ContentsManager,
Kernel,
KernelSpecManager,
Session,
SessionManager
} from '@jupyterlab/services';
import * as uuid from 'uuid/v4';
import { CancellationToken, CancellationTokenSource } from 'vscode-jsonrpc';
import { Cancellation } from '../../../platform/common/cancellation';
Expand All @@ -21,15 +28,18 @@ import {
isLocalConnection,
IJupyterConnection,
ISessionWithSocket,
KernelActionSource
KernelActionSource,
IJupyterServerSession
} from '../../types';
import { DisplayOptions } from '../../displayOptions';
import { IJupyterBackingFileCreator, IJupyterKernelService, IJupyterRequestCreator } from '../types';
import { IBackupFile, IJupyterBackingFileCreator, IJupyterKernelService, IJupyterRequestCreator } from '../types';
import { Uri } from 'vscode';
import { generateBackingIPyNbFileName } from './backingFileCreator.base';

// function is
export class JupyterSession extends BaseJupyterSession {
export class JupyterSession extends BaseJupyterSession implements IJupyterServerSession {
public override readonly kind: 'remoteJupyter' | 'localJupyter';

constructor(
resource: Resource,
private connInfo: IJupyterConnection,
Expand All @@ -53,6 +63,12 @@ export class JupyterSession extends BaseJupyterSession {
workingDirectory,
interruptTimeout
);

this.kind = connInfo.localLaunch ? 'localJupyter' : 'remoteJupyter';
}

public override isServerSession(): this is IJupyterServerSession {
return true;
}

@captureTelemetry(Telemetry.WaitForIdleJupyter, undefined, true)
Expand Down Expand Up @@ -190,6 +206,53 @@ export class JupyterSession extends BaseJupyterSession {
return promise;
}

async invokeWithFileSynced(contents: string, handler: (file: IBackupFile) => Promise<void>): Promise<void> {
if (!this.resource) {
return;
}

const backingFile = await this.backingFileCreator.createBackingFile(
this.resource,
this.workingDirectory,
this.kernelConnectionMetadata,
this.connInfo,
this.contentsManager
);

if (!backingFile) {
return;
}

await this.contentsManager
.save(backingFile!.filePath, {
content: JSON.parse(contents),
type: 'notebook'
})
.ignoreErrors();

await handler({
filePath: backingFile.filePath,
dispose: backingFile.dispose.bind(backingFile)
});

await backingFile.dispose();
await this.contentsManager.delete(backingFile.filePath).ignoreErrors();
}

async createTempfile(ext: string): Promise<string> {
const tempFile = await this.contentsManager.newUntitled({ type: 'file', ext });
return tempFile.path;
}

async deleteTempfile(file: string): Promise<void> {
await this.contentsManager.delete(file);
}

async getContents(file: string, format: Contents.FileFormat): Promise<Contents.IModel> {
const data = await this.contentsManager.get(file, { type: 'file', format: format, content: true });
return data;
}

private async createSession(options: {
token: CancellationToken;
ui: IDisplayOptions;
Expand Down Expand Up @@ -283,7 +346,7 @@ export class JupyterSession extends BaseJupyterSession {
throw new JupyterSessionStartError(new Error(`No kernel created`));
})
.catch((ex) => Promise.reject(new JupyterSessionStartError(ex)))
.finally(() => {
.finally(async () => {
if (this.connInfo && backingFile) {
this.contentsManager.delete(backingFile.filePath).ignoreErrors();
}
Expand Down
7 changes: 6 additions & 1 deletion src/kernels/jupyter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,11 @@ export interface IJupyterServerUriStorage {
setUriToRemote(uri: string, displayName: string): Promise<void>;
}

export interface IBackupFile {
dispose: () => Promise<unknown>;
filePath: string;
}

export const IJupyterBackingFileCreator = Symbol('IJupyterBackingFileCreator');
export interface IJupyterBackingFileCreator {
createBackingFile(
Expand All @@ -250,7 +255,7 @@ export interface IJupyterBackingFileCreator {
kernel: KernelConnectionMetadata,
connInfo: IJupyterConnection,
contentsManager: ContentsManager
): Promise<{ dispose: () => Promise<unknown>; filePath: string } | undefined>;
): Promise<IBackupFile | undefined>;
}

export const IJupyterKernelService = Symbol('IJupyterKernelService');
Expand Down
13 changes: 11 additions & 2 deletions src/kernels/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

'use strict';

import type { Kernel, KernelMessage, Session } from '@jupyterlab/services';
import type { Contents, Kernel, KernelMessage, Session } from '@jupyterlab/services';
import type { Observable } from 'rxjs/Observable';
import type { JSONObject } from '@lumino/coreutils';
import type {
Expand All @@ -19,7 +19,7 @@ import type * as nbformat from '@jupyterlab/nbformat';
import { PythonEnvironment } from '../platform/pythonEnvironments/info';
import { IAsyncDisposable, IDisplayOptions, Resource } from '../platform/common/types';
import { WebSocketData } from '../platform/api/extension';
import { IJupyterKernel } from './jupyter/types';
import { IBackupFile, IJupyterKernel } from './jupyter/types';
import { PythonEnvironment_PythonApi } from '../platform/api/types';

export type LiveKernelModel = IJupyterKernel &
Expand Down Expand Up @@ -262,6 +262,7 @@ export interface IJupyterSession extends IAsyncDisposable {
readonly status: KernelMessage.Status;
readonly kernelId: string;
readonly kernelSocket: Observable<KernelSocketInformation | undefined>;
isServerSession(): this is IJupyterServerSession;
onSessionStatusChanged: Event<KernelMessage.Status>;
onDidDispose: Event<void>;
onIOPubMessage: Event<KernelMessage.IIOPubMessage>;
Expand Down Expand Up @@ -293,6 +294,14 @@ export interface IJupyterSession extends IAsyncDisposable {
shutdown(): Promise<void>;
}

export interface IJupyterServerSession extends IJupyterSession {
readonly kind: 'remoteJupyter' | 'localJupyter';
invokeWithFileSynced(contents: string, handler: (file: IBackupFile) => Promise<void>): Promise<void>;
createTempfile(ext: string): Promise<string>;
deleteTempfile(file: string): Promise<void>;
getContents(file: string, format: Contents.FileFormat): Promise<Contents.IModel>;
}

export type ISessionWithSocket = Session.ISessionConnection & {
/**
* The resource associated with this session.
Expand Down
15 changes: 0 additions & 15 deletions src/platform/export/export.index.node.ts

This file was deleted.

84 changes: 71 additions & 13 deletions src/platform/export/exportBase.node.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,83 @@
import { inject, injectable } from 'inversify';
import * as path from '../../platform/vscode-path/path';
import { CancellationToken, Uri } from 'vscode';
import { CancellationToken, NotebookDocument, Uri } from 'vscode';
import { INotebookImporter } from '../../kernels/jupyter/types';
import { IJupyterSubCommandExecutionService } from '../../kernels/jupyter/types.node';
import { IFileSystemNode } from '../common/platform/types.node';
import { IPythonExecutionFactory, IPythonExecutionService } from '../common/process/types.node';

import { reportAction } from '../progress/decorator.node';
import { reportAction } from '../progress/decorator';
import { ReportableAction } from '../progress/types';
import { PythonEnvironment } from '../pythonEnvironments/info';
import { ExportFormat, INbConvertExport } from './types';
import { ExportFormat, IExportBase, IExportDialog, INbConvertExport } from './types';
import { ExportUtil } from './exportUtil.node';
import { TemporaryDirectory } from '../common/platform/types';
import { ExportInterpreterFinder } from './exportInterpreterFinder.node';

@injectable()
export class ExportBase implements INbConvertExport {
export class ExportBase implements INbConvertExport, IExportBase {
constructor(
@inject(IPythonExecutionFactory) protected readonly pythonExecutionFactory: IPythonExecutionFactory,
@inject(IJupyterSubCommandExecutionService)
protected jupyterService: IJupyterSubCommandExecutionService,
@inject(IFileSystemNode) protected readonly fs: IFileSystemNode,
@inject(INotebookImporter) protected readonly importer: INotebookImporter
@inject(IExportDialog) protected readonly filePicker: IExportDialog,
@inject(ExportUtil) protected readonly exportUtil: ExportUtil,
@inject(INotebookImporter) protected readonly importer: INotebookImporter,
@inject(ExportInterpreterFinder) private exportInterpreterFinder: ExportInterpreterFinder
) {}

public async export(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this method needed in these classes?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still put it there since we register it as serviceManager.addSingleton<INbConvertExport>(INbConvertExport, ExportBase, 'Export Base');, not sure why it's needed there. I can send a pr later to have it fixed.

_source: Uri,
_target: Uri,
_sourceDocument: NotebookDocument,
_interpreter: PythonEnvironment,
_defaultFileName: string | undefined,
_token: CancellationToken
// eslint-disable-next-line no-empty,@typescript-eslint/no-empty-function
): Promise<void> {}
): Promise<Uri | undefined> {
return undefined;
}

@reportAction(ReportableAction.PerformingExport)
public async executeCommand(
source: Uri,
target: Uri,
sourceDocument: NotebookDocument,
defaultFileName: string | undefined,
format: ExportFormat,
interpreter: PythonEnvironment,
interpreter: PythonEnvironment | undefined,
token: CancellationToken
): Promise<void> {
): Promise<Uri | undefined> {
if (token.isCancellationRequested) {
return;
}

let target = await this.getTargetFile(format, sourceDocument.uri, defaultFileName);

if (!target) {
return;
}

interpreter = await this.exportInterpreterFinder.getExportInterpreter(interpreter);

if (format === ExportFormat.python) {
const contents = await this.importer.importFromFile(sourceDocument.uri, interpreter);
await this.fs.writeFile(target, contents);
return;
}

let contents = await this.exportUtil.getContent(sourceDocument);

if (format === ExportFormat.pdf) {
// When exporting to PDF we need to remove any SVG output. This is due to an error
// with nbconvert and a dependency of its called InkScape.
contents = await this.exportUtil.removeSvgs(contents);
}

/* Need to make a temp directory here, instead of just a temp file. This is because
we need to store the contents of the notebook in a file that is named the same
as what we want the title of the exported file to be. To ensure this file path will be unique
we store it in a temp directory. The name of the file matters because when
exporting to certain formats the filename is used within the exported document as the title. */
const tempDir = await this.exportUtil.generateTempDir();
const source = await this.makeSourceFile(target, contents, tempDir);

const service = await this.getExecutionService(source, interpreter);
if (!service) {
return;
Expand Down Expand Up @@ -82,6 +119,27 @@ export class ExportBase implements INbConvertExport {
} finally {
tempTarget.dispose();
}

return target;
}

async getTargetFile(format: ExportFormat, source: Uri, defaultFileName?: string): Promise<Uri | undefined> {
let target;

if (format !== ExportFormat.python) {
target = await this.filePicker.showDialog(format, source, defaultFileName);
} else {
target = Uri.file((await this.fs.createTemporaryLocalFile('.py')).filePath);
}

return target;
}

private async makeSourceFile(target: Uri, contents: string, tempDir: TemporaryDirectory): Promise<Uri> {
// Creates a temporary file with the same base name as the target file
const fileName = path.basename(target.fsPath, path.extname(target.fsPath));
const sourceFilePath = await this.exportUtil.makeFileInDirectory(contents, `${fileName}.ipynb`, tempDir.path);
return Uri.file(sourceFilePath);
}

protected async getExecutionService(
Expand Down
Loading