Skip to content

Commit 5991ee1

Browse files
IanMatthewHuffIan Huff
authored and
Ian Huff
committed
NB Convert 6.0 support for export (microsoft#14177)
1 parent f276e07 commit 5991ee1

18 files changed

+143
-65
lines changed

news/2 Fixes/14169.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Support nbconvert version 6+ for exporting notebooks to python code.

package.nls.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
"DataScience.openExportFileYes": "Yes",
3232
"DataScience.openExportFileNo": "No",
3333
"DataScience.failedExportMessage": "Export failed.",
34-
"DataScience.exportFailedGeneralMessage": "Export failed. Please check the 'Python' [output](command:python.viewOutput) panel for further details.",
34+
"DataScience.exportFailedGeneralMessage": "Please check the 'Python' [output](command:python.viewOutput) panel for further details.",
3535
"DataScience.exportToPDFDependencyMessage": "If you have not installed xelatex (TeX) you will need to do so before you can export to PDF, for further instructions go to https://nbconvert.readthedocs.io/en/latest/install.html#installing-tex. \r\nTo avoid installing xelatex (TeX) you might want to try exporting to HTML and using your browsers \"Print to PDF\" feature.",
3636
"DataScience.launchNotebookTrustPrompt": "A notebook could execute harmful code when opened. Some outputs have been hidden. Do you trust this notebook? [Learn more.](https://aka.ms/trusted-notebooks)",
3737
"DataScience.launchNotebookTrustPrompt.yes": "Trust",

src/client/common/utils/localize.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -892,7 +892,7 @@ export namespace DataScience {
892892
export const openExportFileNo = localize('DataScience.openExportFileNo', 'No');
893893
export const exportFailedGeneralMessage = localize(
894894
'DataScience.exportFailedGeneralMessage',
895-
`Export failed. Please check the 'Python' [output](command:python.viewOutput) panel for further details.`
895+
`Please check the 'Python' [output](command:python.viewOutput) panel for further details.`
896896
);
897897
export const exportToPDFDependencyMessage = localize(
898898
'DataScience.exportToPDFDependencyMessage',

src/client/datascience/common.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
'use strict';
44
import type { nbformat } from '@jupyterlab/coreutils';
55
import * as os from 'os';
6+
import { parse, SemVer } from 'semver';
67
import { Memento, Uri } from 'vscode';
78
import { splitMultilineString } from '../../datascience-ui/common';
89
import { traceError, traceInfo } from '../common/logger';
@@ -188,3 +189,14 @@ export async function getRealPath(
188189
}
189190
}
190191
}
192+
193+
// For the given string parse it out to a SemVer or return undefined
194+
export function parseSemVer(versionString: string): SemVer | undefined {
195+
const versionMatch = /^\s*(\d+)\.(\d+)\.(.+)\s*$/.exec(versionString);
196+
if (versionMatch && versionMatch.length > 2) {
197+
const major = parseInt(versionMatch[1], 10);
198+
const minor = parseInt(versionMatch[2], 10);
199+
const build = parseInt(versionMatch[3], 10);
200+
return parse(`${major}.${minor}.${build}`, true) ?? undefined;
201+
}
202+
}

src/client/datascience/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -569,7 +569,7 @@ export namespace LiveShare {
569569

570570
export namespace LiveShareCommands {
571571
export const isNotebookSupported = 'isNotebookSupported';
572-
export const isImportSupported = 'isImportSupported';
572+
export const getImportPackageVersion = 'getImportPackageVersion';
573573
export const connectToNotebookServer = 'connectToNotebookServer';
574574
export const getUsableJupyterPython = 'getUsableJupyterPython';
575575
export const executeObservable = 'executeObservable';

src/client/datascience/data-viewing/dataViewerDependencyService.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
'use strict';
55

66
import { inject, injectable } from 'inversify';
7-
import { parse, SemVer } from 'semver';
7+
import { SemVer } from 'semver';
88
import { CancellationToken } from 'vscode';
99
import { IApplicationShell } from '../../common/application/types';
1010
import { Cancellation, createPromiseFromCancellation, wrapCancellationTokens } from '../../common/cancellation';
@@ -14,6 +14,7 @@ import { IInstaller, InstallerResponse, Product } from '../../common/types';
1414
import { Common, DataScience } from '../../common/utils/localize';
1515
import { PythonEnvironment } from '../../pythonEnvironments/info';
1616
import { sendTelemetryEvent } from '../../telemetry';
17+
import { parseSemVer } from '../common';
1718
import { Telemetry } from '../constants';
1819

1920
const minimumSupportedPandaVersion = '0.20.0';
@@ -104,13 +105,8 @@ export class DataViewerDependencyService {
104105
throwOnStdErr: true,
105106
token
106107
});
107-
const versionMatch = /^\s*(\d+)\.(\d+)\.(.+)\s*$/.exec(result.stdout);
108-
if (versionMatch && versionMatch.length > 2) {
109-
const major = parseInt(versionMatch[1], 10);
110-
const minor = parseInt(versionMatch[2], 10);
111-
const build = parseInt(versionMatch[3], 10);
112-
return parse(`${major}.${minor}.${build}`, true) ?? undefined;
113-
}
108+
109+
return parseSemVer(result.stdout);
114110
} catch (ex) {
115111
traceWarning('Failed to get version of Pandas to use Data Viewer', ex);
116112
return;

src/client/datascience/export/exportDependencyChecker.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ export class ExportDependencyChecker {
1717
// Before we try the import, see if we don't support it, if we don't give a chance to install dependencies
1818
const reporter = this.progressReporter.createProgressIndicator(`Exporting to ${format}`);
1919
try {
20-
if (!(await this.jupyterExecution.isImportSupported())) {
20+
if (!(await this.jupyterExecution.getImportPackageVersion())) {
2121
await this.dependencyManager.installMissingDependencies();
22-
if (!(await this.jupyterExecution.isImportSupported())) {
22+
if (!(await this.jupyterExecution.getImportPackageVersion())) {
2323
throw new Error(localize.DataScience.jupyterNbConvertNotSupported());
2424
}
2525
}

src/client/datascience/jupyter/interpreter/jupyterInterpreterDependencyService.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
'use strict';
55

66
import { inject, injectable } from 'inversify';
7+
import { SemVer } from 'semver';
78
import { CancellationToken } from 'vscode';
89
import { IApplicationShell } from '../../../common/application/types';
910
import { Cancellation, createPromiseFromCancellation, wrapCancellationTokens } from '../../../common/cancellation';
@@ -14,6 +15,7 @@ import { Common, DataScience } from '../../../common/utils/localize';
1415
import { noop } from '../../../common/utils/misc';
1516
import { PythonEnvironment } from '../../../pythonEnvironments/info';
1617
import { sendTelemetryEvent } from '../../../telemetry';
18+
import { parseSemVer } from '../../common';
1719
import { HelpLinks, JupyterCommands, Telemetry } from '../../constants';
1820
import { reportAction } from '../../progress/decorator';
1921
import { ReportableAction } from '../../progress/types';
@@ -241,6 +243,23 @@ export class JupyterInterpreterDependencyService {
241243
return installed;
242244
}
243245

246+
public async getNbConvertVersion(
247+
interpreter: PythonEnvironment,
248+
_token?: CancellationToken
249+
): Promise<SemVer | undefined> {
250+
const command = this.commandFactory.createInterpreterCommand(
251+
JupyterCommands.ConvertCommand,
252+
'jupyter',
253+
['-m', 'jupyter', 'nbconvert'],
254+
interpreter,
255+
false
256+
);
257+
258+
const result = await command.exec(['--version'], { throwOnStdErr: true });
259+
260+
return parseSemVer(result.stdout);
261+
}
262+
244263
/**
245264
* Gets a list of the dependencies not installed, dependencies that are required to launch the jupyter notebook server.
246265
*

src/client/datascience/jupyter/interpreter/jupyterInterpreterSubCommandExecutionService.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import { inject, injectable, named } from 'inversify';
77
import * as path from 'path';
8+
import { SemVer } from 'semver';
89
import { CancellationToken, Uri } from 'vscode';
910
import { Cancellation } from '../../../common/cancellation';
1011
import { traceError, traceInfo, traceWarning } from '../../../common/logger';
@@ -76,12 +77,16 @@ export class JupyterInterpreterSubCommandExecutionService
7677
}
7778
return this.jupyterDependencyService.areDependenciesInstalled(interpreter, token);
7879
}
79-
public async isExportSupported(token?: CancellationToken): Promise<boolean> {
80+
public async getExportPackageVersion(token?: CancellationToken): Promise<SemVer | undefined> {
8081
const interpreter = await this.jupyterInterpreter.getSelectedInterpreter(token);
8182
if (!interpreter) {
82-
return false;
83+
return;
84+
}
85+
86+
// If nbconvert is there check and return the version
87+
if (await this.jupyterDependencyService.isExportSupported(interpreter, token)) {
88+
return this.jupyterDependencyService.getNbConvertVersion(interpreter, token);
8389
}
84-
return this.jupyterDependencyService.isExportSupported(interpreter, token);
8590
}
8691
public async getReasonForJupyterNotebookNotBeingSupported(token?: CancellationToken): Promise<string> {
8792
let interpreter = await this.jupyterInterpreter.getSelectedInterpreter(token);
@@ -176,11 +181,21 @@ export class JupyterInterpreterSubCommandExecutionService
176181
const args = template
177182
? [file.fsPath, '--to', 'python', '--stdout', '--template', template]
178183
: [file.fsPath, '--to', 'python', '--stdout'];
184+
179185
// Ignore stderr, as nbconvert writes conversion result to stderr.
180186
// stdout contains the generated python code.
181187
return daemon
182188
.execModule('jupyter', ['nbconvert'].concat(args), { throwOnStdErr: false, encoding: 'utf8', token })
183-
.then((output) => output.stdout);
189+
.then((output) => {
190+
// We can't check stderr (as nbconvert puts diag output there) but we need to verify here that we actually
191+
// converted something. If it's zero size then just raise an error
192+
if (output.stdout === '') {
193+
traceError('nbconvert zero size output');
194+
throw new Error(output.stderr);
195+
} else {
196+
return output.stdout;
197+
}
198+
});
184199
}
185200
public async openNotebook(notebookFile: string): Promise<void> {
186201
const interpreter = await this.getSelectedInterpreterAndThrowIfNotAvailable();

src/client/datascience/jupyter/jupyterExecution.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the MIT License.
33
'use strict';
44
import * as path from 'path';
5+
import { SemVer } from 'semver';
56
import * as uuid from 'uuid/v4';
67
import { CancellationToken, CancellationTokenSource, Event, EventEmitter, Uri } from 'vscode';
78

@@ -123,9 +124,9 @@ export class JupyterExecutionBase implements IJupyterExecution {
123124
}
124125

125126
@reportAction(ReportableAction.CheckingIfImportIsSupported)
126-
public async isImportSupported(cancelToken?: CancellationToken): Promise<boolean> {
127+
public async getImportPackageVersion(cancelToken?: CancellationToken): Promise<SemVer | undefined> {
127128
// See if we can find the command nbconvert
128-
return this.jupyterInterpreterService.isExportSupported(cancelToken);
129+
return this.jupyterInterpreterService.getExportPackageVersion(cancelToken);
129130
}
130131

131132
public isSpawnSupported(cancelToken?: CancellationToken): Promise<boolean> {

src/client/datascience/jupyter/jupyterExecutionFactory.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the MIT License.
33
'use strict';
44
import { inject, injectable, named } from 'inversify';
5+
import { SemVer } from 'semver';
56
import { CancellationToken, Event, EventEmitter, Uri } from 'vscode';
67

78
import { IApplicationShell, ILiveShareApi, IWorkspaceService } from '../../common/application/types';
@@ -117,9 +118,9 @@ export class JupyterExecutionFactory implements IJupyterExecution, IAsyncDisposa
117118
return execution.getNotebookError();
118119
}
119120

120-
public async isImportSupported(cancelToken?: CancellationToken): Promise<boolean> {
121+
public async getImportPackageVersion(cancelToken?: CancellationToken): Promise<SemVer | undefined> {
121122
const execution = await this.executionFactory.get();
122-
return execution.isImportSupported(cancelToken);
123+
return execution.getImportPackageVersion(cancelToken);
123124
}
124125
public async isSpawnSupported(cancelToken?: CancellationToken): Promise<boolean> {
125126
const execution = await this.executionFactory.get();

src/client/datascience/jupyter/jupyterImporter.ts

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,20 +26,22 @@ import {
2626
export class JupyterImporter implements INotebookImporter {
2727
public isDisposed: boolean = false;
2828
// Template that changes markdown cells to have # %% [markdown] in the comments
29-
private readonly nbconvertTemplateFormat =
29+
private readonly nbconvertBaseTemplateFormat =
3030
// tslint:disable-next-line:no-multiline-string
31-
`{%- extends 'null.tpl' -%}
31+
`{%- extends '{0}' -%}
3232
{% block codecell %}
33-
{0}
33+
{1}
3434
{{ super() }}
3535
{% endblock codecell %}
3636
{% block in_prompt %}{% endblock in_prompt %}
3737
{% block input %}{{ cell.source | ipython2python }}{% endblock input %}
3838
{% block markdowncell scoped %}{0} [markdown]
3939
{{ cell.source | comment_lines }}
4040
{% endblock markdowncell %}`;
41-
42-
private templatePromise: Promise<string | undefined>;
41+
private readonly nbconvert5Null = 'null.tpl';
42+
private readonly nbconvert6Null = 'base/null.j2';
43+
private template5Promise?: Promise<string | undefined>;
44+
private template6Promise?: Promise<string | undefined>;
4345

4446
constructor(
4547
@inject(IDataScienceFileSystem) private fs: IDataScienceFileSystem,
@@ -50,13 +52,9 @@ export class JupyterImporter implements INotebookImporter {
5052
@inject(IPlatformService) private readonly platform: IPlatformService,
5153
@inject(IJupyterInterpreterDependencyManager)
5254
private readonly dependencyManager: IJupyterInterpreterDependencyManager
53-
) {
54-
this.templatePromise = this.createTemplateFile();
55-
}
55+
) {}
5656

5757
public async importFromFile(sourceFile: Uri): Promise<string> {
58-
const template = await this.templatePromise;
59-
6058
// If the user has requested it, add a cd command to the imported file so that relative paths still work
6159
const settings = this.configuration.getSettings();
6260
let directoryChange: string | undefined;
@@ -65,12 +63,30 @@ export class JupyterImporter implements INotebookImporter {
6563
}
6664

6765
// Before we try the import, see if we don't support it, if we don't give a chance to install dependencies
68-
if (!(await this.jupyterExecution.isImportSupported())) {
66+
if (!(await this.jupyterExecution.getImportPackageVersion())) {
6967
await this.dependencyManager.installMissingDependencies();
7068
}
7169

70+
const nbConvertVersion = await this.jupyterExecution.getImportPackageVersion();
7271
// Use the jupyter nbconvert functionality to turn the notebook into a python file
73-
if (await this.jupyterExecution.isImportSupported()) {
72+
if (nbConvertVersion) {
73+
// nbconvert 5 and 6 use a different base template file
74+
// Create and select the correct one
75+
let template: string | undefined;
76+
if (nbConvertVersion.major >= 6) {
77+
if (!this.template6Promise) {
78+
this.template6Promise = this.createTemplateFile(true);
79+
}
80+
81+
template = await this.template6Promise;
82+
} else {
83+
if (!this.template5Promise) {
84+
this.template5Promise = this.createTemplateFile(false);
85+
}
86+
87+
template = await this.template5Promise;
88+
}
89+
7490
let fileOutput: string = await this.jupyterExecution.importNotebook(sourceFile, template);
7591
if (fileOutput.includes('get_ipython()')) {
7692
fileOutput = this.addIPythonImport(fileOutput);
@@ -153,7 +169,7 @@ export class JupyterImporter implements INotebookImporter {
153169
}
154170
}
155171

156-
private async createTemplateFile(): Promise<string | undefined> {
172+
private async createTemplateFile(nbconvert6: boolean): Promise<string | undefined> {
157173
// Create a temp file on disk
158174
const file = await this.fs.createTemporaryLocalFile('.tpl');
159175

@@ -164,7 +180,10 @@ export class JupyterImporter implements INotebookImporter {
164180
this.disposableRegistry.push(file);
165181
await this.fs.appendLocalFile(
166182
file.filePath,
167-
this.nbconvertTemplateFormat.format(this.defaultCellMarker)
183+
this.nbconvertBaseTemplateFormat.format(
184+
nbconvert6 ? this.nbconvert6Null : this.nbconvert5Null,
185+
this.defaultCellMarker
186+
)
168187
);
169188

170189
// Now we should have a template that will convert

src/client/datascience/jupyter/liveshare/guestJupyterExecution.ts

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the MIT License.
33
'use strict';
44
import { injectable } from 'inversify';
5+
import { SemVer } from 'semver';
56
import * as uuid from 'uuid/v4';
67
import { CancellationToken } from 'vscode';
78

@@ -72,10 +73,27 @@ export class GuestJupyterExecution extends LiveShareParticipantGuest(
7273
}
7374

7475
public async isNotebookSupported(cancelToken?: CancellationToken): Promise<boolean> {
75-
return this.checkSupported(LiveShareCommands.isNotebookSupported, cancelToken);
76+
const service = await this.waitForService();
77+
78+
// Make a remote call on the proxy
79+
if (service) {
80+
const result = await service.request(LiveShareCommands.isNotebookSupported, [], cancelToken);
81+
return result as boolean;
82+
}
83+
84+
return false;
7685
}
77-
public isImportSupported(cancelToken?: CancellationToken): Promise<boolean> {
78-
return this.checkSupported(LiveShareCommands.isImportSupported, cancelToken);
86+
public async getImportPackageVersion(cancelToken?: CancellationToken): Promise<SemVer | undefined> {
87+
const service = await this.waitForService();
88+
89+
// Make a remote call on the proxy
90+
if (service) {
91+
const result = await service.request(LiveShareCommands.getImportPackageVersion, [], cancelToken);
92+
93+
if (result) {
94+
return result as SemVer;
95+
}
96+
}
7997
}
8098
public isSpawnSupported(_cancelToken?: CancellationToken): Promise<boolean> {
8199
return Promise.resolve(false);
@@ -144,16 +162,4 @@ export class GuestJupyterExecution extends LiveShareParticipantGuest(
144162
public async getServer(options?: INotebookServerOptions): Promise<INotebookServer | undefined> {
145163
return this.serverCache.get(options);
146164
}
147-
148-
private async checkSupported(command: string, cancelToken?: CancellationToken): Promise<boolean> {
149-
const service = await this.waitForService();
150-
151-
// Make a remote call on the proxy
152-
if (service) {
153-
const result = await service.request(command, [], cancelToken);
154-
return result as boolean;
155-
}
156-
157-
return false;
158-
}
159165
}

0 commit comments

Comments
 (0)