Skip to content

Commit ca0d608

Browse files
NB Convert 6.0 support for export (#14177) (#170)
1 parent 978b896 commit ca0d608

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
@@ -722,7 +722,7 @@ export namespace DataScience {
722722
export const openExportFileNo = localize('DataScience.openExportFileNo', 'No');
723723
export const exportFailedGeneralMessage = localize(
724724
'DataScience.exportFailedGeneralMessage',
725-
`Export failed. Please check the 'Python' [output](command:python.viewOutput) panel for further details.`
725+
`Please check the 'Python' [output](command:python.viewOutput) panel for further details.`
726726
);
727727
export const exportToPDFDependencyMessage = localize(
728728
'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
@@ -554,7 +554,7 @@ export namespace LiveShare {
554554

555555
export namespace LiveShareCommands {
556556
export const isNotebookSupported = 'isNotebookSupported';
557-
export const isImportSupported = 'isImportSupported';
557+
export const getImportPackageVersion = 'getImportPackageVersion';
558558
export const connectToNotebookServer = 'connectToNotebookServer';
559559
export const getUsableJupyterPython = 'getUsableJupyterPython';
560560
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';
@@ -72,12 +73,16 @@ export class JupyterInterpreterSubCommandExecutionService
7273
}
7374
return this.jupyterDependencyService.areDependenciesInstalled(interpreter, token);
7475
}
75-
public async isExportSupported(token?: CancellationToken): Promise<boolean> {
76+
public async getExportPackageVersion(token?: CancellationToken): Promise<SemVer | undefined> {
7677
const interpreter = await this.jupyterInterpreter.getSelectedInterpreter(token);
7778
if (!interpreter) {
78-
return false;
79+
return;
80+
}
81+
82+
// If nbconvert is there check and return the version
83+
if (await this.jupyterDependencyService.isExportSupported(interpreter, token)) {
84+
return this.jupyterDependencyService.getNbConvertVersion(interpreter, token);
7985
}
80-
return this.jupyterDependencyService.isExportSupported(interpreter, token);
8186
}
8287
public async getReasonForJupyterNotebookNotBeingSupported(token?: CancellationToken): Promise<string> {
8388
let interpreter = await this.jupyterInterpreter.getSelectedInterpreter(token);
@@ -172,11 +177,21 @@ export class JupyterInterpreterSubCommandExecutionService
172177
const args = template
173178
? [file.fsPath, '--to', 'python', '--stdout', '--template', template]
174179
: [file.fsPath, '--to', 'python', '--stdout'];
180+
175181
// Ignore stderr, as nbconvert writes conversion result to stderr.
176182
// stdout contains the generated python code.
177183
return daemon
178184
.execModule('jupyter', ['nbconvert'].concat(args), { throwOnStdErr: false, encoding: 'utf8', token })
179-
.then((output) => output.stdout);
185+
.then((output) => {
186+
// We can't check stderr (as nbconvert puts diag output there) but we need to verify here that we actually
187+
// converted something. If it's zero size then just raise an error
188+
if (output.stdout === '') {
189+
traceError('nbconvert zero size output');
190+
throw new Error(output.stderr);
191+
} else {
192+
return output.stdout;
193+
}
194+
});
180195
}
181196
public async openNotebook(notebookFile: string): Promise<void> {
182197
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
@@ -21,20 +21,22 @@ import { IFileSystem, IJupyterExecution, IJupyterInterpreterDependencyManager, I
2121
export class JupyterImporter implements INotebookImporter {
2222
public isDisposed: boolean = false;
2323
// Template that changes markdown cells to have # %% [markdown] in the comments
24-
private readonly nbconvertTemplateFormat =
24+
private readonly nbconvertBaseTemplateFormat =
2525
// tslint:disable-next-line:no-multiline-string
26-
`{%- extends 'null.tpl' -%}
26+
`{%- extends '{0}' -%}
2727
{% block codecell %}
28-
{0}
28+
{1}
2929
{{ super() }}
3030
{% endblock codecell %}
3131
{% block in_prompt %}{% endblock in_prompt %}
3232
{% block input %}{{ cell.source | ipython2python }}{% endblock input %}
3333
{% block markdowncell scoped %}{0} [markdown]
3434
{{ cell.source | comment_lines }}
3535
{% endblock markdowncell %}`;
36-
37-
private templatePromise: Promise<string | undefined>;
36+
private readonly nbconvert5Null = 'null.tpl';
37+
private readonly nbconvert6Null = 'base/null.j2';
38+
private template5Promise?: Promise<string | undefined>;
39+
private template6Promise?: Promise<string | undefined>;
3840

3941
constructor(
4042
@inject(IFileSystem) private fs: IFileSystem,
@@ -45,13 +47,9 @@ export class JupyterImporter implements INotebookImporter {
4547
@inject(IPlatformService) private readonly platform: IPlatformService,
4648
@inject(IJupyterInterpreterDependencyManager)
4749
private readonly dependencyManager: IJupyterInterpreterDependencyManager
48-
) {
49-
this.templatePromise = this.createTemplateFile();
50-
}
50+
) {}
5151

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

6260
// Before we try the import, see if we don't support it, if we don't give a chance to install dependencies
63-
if (!(await this.jupyterExecution.isImportSupported())) {
61+
if (!(await this.jupyterExecution.getImportPackageVersion())) {
6462
await this.dependencyManager.installMissingDependencies();
6563
}
6664

65+
const nbConvertVersion = await this.jupyterExecution.getImportPackageVersion();
6766
// Use the jupyter nbconvert functionality to turn the notebook into a python file
68-
if (await this.jupyterExecution.isImportSupported()) {
67+
if (nbConvertVersion) {
68+
// nbconvert 5 and 6 use a different base template file
69+
// Create and select the correct one
70+
let template: string | undefined;
71+
if (nbConvertVersion.major >= 6) {
72+
if (!this.template6Promise) {
73+
this.template6Promise = this.createTemplateFile(true);
74+
}
75+
76+
template = await this.template6Promise;
77+
} else {
78+
if (!this.template5Promise) {
79+
this.template5Promise = this.createTemplateFile(false);
80+
}
81+
82+
template = await this.template5Promise;
83+
}
84+
6985
let fileOutput: string = await this.jupyterExecution.importNotebook(sourceFile, template);
7086
if (fileOutput.includes('get_ipython()')) {
7187
fileOutput = this.addIPythonImport(fileOutput);
@@ -148,7 +164,7 @@ export class JupyterImporter implements INotebookImporter {
148164
}
149165
}
150166

151-
private async createTemplateFile(): Promise<string | undefined> {
167+
private async createTemplateFile(nbconvert6: boolean): Promise<string | undefined> {
152168
// Create a temp file on disk
153169
const file = await this.fs.createTemporaryLocalFile('.tpl');
154170

@@ -159,7 +175,10 @@ export class JupyterImporter implements INotebookImporter {
159175
this.disposableRegistry.push(file);
160176
await this.fs.appendLocalFile(
161177
file.filePath,
162-
this.nbconvertTemplateFormat.format(this.defaultCellMarker)
178+
this.nbconvertBaseTemplateFormat.format(
179+
nbconvert6 ? this.nbconvert6Null : this.nbconvert5Null,
180+
this.defaultCellMarker
181+
)
163182
);
164183

165184
// 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)