Skip to content

Commit d30b8bc

Browse files
committed
Internalize the notebook cell executor
1 parent 179c3de commit d30b8bc

File tree

6 files changed

+10884
-86
lines changed

6 files changed

+10884
-86
lines changed

jupyter_server_config.py

Lines changed: 0 additions & 1 deletion
This file was deleted.

package.json

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,16 @@
5353
"watch:labextension": "jupyter labextension watch ."
5454
},
5555
"dependencies": {
56-
"@jupyterlab/application": "^4.0.0",
57-
"@jupyterlab/coreutils": "^6.0.0",
58-
"@jupyterlab/services": "^7.0.0"
56+
"@jupyterlab/application": "^4.2.0",
57+
"@jupyterlab/apputils": "^4.2.0",
58+
"@jupyterlab/cells": "^4.2.0",
59+
"@jupyterlab/coreutils": "^6.2.0",
60+
"@jupyterlab/notebook": "^4.2.0",
61+
"@jupyterlab/outputarea": "^4.2.0",
62+
"@jupyterlab/services": "^7.2.0",
63+
"@jupyterlab/translation": "^4.2.0",
64+
"@lumino/coreutils": "^2.1.0",
65+
"@lumino/widgets": "^2.3.0"
5966
},
6067
"devDependencies": {
6168
"@jupyterlab/builder": "^4.0.0",
@@ -95,15 +102,19 @@
95102
},
96103
"jupyterlab": {
97104
"discovery": {
98-
"server": {
99-
"managers": [
100-
"pip"
101-
],
102-
"base": {
103-
"name": "jupyter_server_nbmodel"
105+
"server": {
106+
"managers": [
107+
"pip"
108+
],
109+
"base": {
110+
"name": "jupyter_server_nbmodel"
111+
}
104112
}
105-
}
106113
},
114+
"disabledExtensions": [
115+
"@jupyterlab/notebook-extension:cell-executor",
116+
"@jupyter/docprovider-extension:notebook-cell-executor"
117+
],
107118
"extension": true,
108119
"outputDir": "jupyter_server_nbmodel/labextension"
109120
},

src/executor.ts

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
import {
2+
JupyterFrontEnd,
3+
JupyterFrontEndPlugin
4+
} from '@jupyterlab/application';
5+
import { Dialog, showDialog } from '@jupyterlab/apputils';
6+
import {
7+
CodeCell,
8+
type ICodeCellModel,
9+
type MarkdownCell
10+
} from '@jupyterlab/cells';
11+
import { URLExt } from '@jupyterlab/coreutils';
12+
import { INotebookCellExecutor } from '@jupyterlab/notebook';
13+
import { OutputPrompt, Stdin } from '@jupyterlab/outputarea';
14+
import { Kernel, ServerConnection } from '@jupyterlab/services';
15+
import * as KernelMessage from '@jupyterlab/services/lib/kernel/messages';
16+
import { nullTranslator, type ITranslator } from '@jupyterlab/translation';
17+
import { PromiseDelegate } from '@lumino/coreutils';
18+
import { Panel } from '@lumino/widgets';
19+
20+
/**
21+
* Polling interval for accepted execution requests.
22+
*/
23+
const MAX_POLLING_INTERVAL = 1000;
24+
25+
/**
26+
* Notebook cell executor posting a request to the server for execution.
27+
*/
28+
export class NotebookCellServerExecutor implements INotebookCellExecutor {
29+
private _serverSettings: ServerConnection.ISettings;
30+
31+
/**
32+
* Constructor
33+
*
34+
* @param options Constructor options; the contents manager, the collaborative drive and optionally the server settings.
35+
*/
36+
constructor(options: { serverSettings?: ServerConnection.ISettings }) {
37+
this._serverSettings =
38+
options.serverSettings ?? ServerConnection.makeSettings();
39+
}
40+
41+
/**
42+
* Execute a given cell of the notebook.
43+
*
44+
* @param options Execution options
45+
* @returns Execution success status
46+
*/
47+
async runCell({
48+
cell,
49+
notebook,
50+
notebookConfig,
51+
onCellExecuted,
52+
onCellExecutionScheduled,
53+
sessionContext,
54+
sessionDialogs,
55+
translator
56+
}: INotebookCellExecutor.IRunCellOptions): Promise<boolean> {
57+
translator = translator ?? nullTranslator;
58+
const trans = translator.load('jupyterlab');
59+
60+
switch (cell.model.type) {
61+
case 'markdown':
62+
(cell as MarkdownCell).rendered = true;
63+
cell.inputHidden = false;
64+
onCellExecuted({ cell, success: true });
65+
break;
66+
case 'code':
67+
if (sessionContext) {
68+
if (sessionContext.isTerminating) {
69+
await showDialog({
70+
title: trans.__('Kernel Terminating'),
71+
body: trans.__(
72+
'The kernel for %1 appears to be terminating. You can not run any cell for now.',
73+
sessionContext.session?.path
74+
),
75+
buttons: [Dialog.okButton()]
76+
});
77+
break;
78+
}
79+
if (sessionContext.pendingInput) {
80+
await showDialog({
81+
title: trans.__('Cell not executed due to pending input'),
82+
body: trans.__(
83+
'The cell has not been executed to avoid kernel deadlock as there is another pending input! Submit your pending input and try again.'
84+
),
85+
buttons: [Dialog.okButton()]
86+
});
87+
return false;
88+
}
89+
if (sessionContext.hasNoKernel) {
90+
const shouldSelect = await sessionContext.startKernel();
91+
if (shouldSelect && sessionDialogs) {
92+
await sessionDialogs.selectKernel(sessionContext);
93+
}
94+
}
95+
96+
if (sessionContext.hasNoKernel) {
97+
cell.model.sharedModel.transact(() => {
98+
(cell.model as ICodeCellModel).clearExecution();
99+
});
100+
return true;
101+
}
102+
103+
const kernelId = sessionContext?.session?.kernel?.id;
104+
const apiURL = URLExt.join(
105+
this._serverSettings.baseUrl,
106+
`api/kernels/${kernelId}/execute`
107+
);
108+
const cellId = cell.model.sharedModel.getId();
109+
const documentId = notebook.sharedModel.getState('document_id');
110+
111+
const init = {
112+
method: 'POST',
113+
body: JSON.stringify({ cell_id: cellId, document_id: documentId })
114+
};
115+
onCellExecutionScheduled({ cell });
116+
let success = false;
117+
try {
118+
// FIXME quid of deletedCells and timing record
119+
const response = await requestServer(
120+
cell as CodeCell,
121+
apiURL,
122+
init,
123+
this._serverSettings,
124+
translator
125+
);
126+
const data = await response.json();
127+
success = data['status'] === 'ok';
128+
} catch (error: unknown) {
129+
onCellExecuted({
130+
cell,
131+
success: false
132+
});
133+
if (cell.isDisposed) {
134+
return false;
135+
} else {
136+
throw error;
137+
}
138+
}
139+
140+
onCellExecuted({ cell, success });
141+
142+
return true;
143+
}
144+
cell.model.sharedModel.transact(() => {
145+
(cell.model as ICodeCellModel).clearExecution();
146+
}, false);
147+
break;
148+
default:
149+
break;
150+
}
151+
return Promise.resolve(true);
152+
}
153+
}
154+
155+
async function requestServer(
156+
cell: CodeCell,
157+
url: string,
158+
init: RequestInit,
159+
settings: ServerConnection.ISettings,
160+
translator?: ITranslator,
161+
interval = 100
162+
): Promise<Response> {
163+
const promise = new PromiseDelegate<Response>();
164+
ServerConnection.makeRequest(url, init, settings)
165+
.then(async response => {
166+
if (!response.ok) {
167+
if (response.status === 300) {
168+
let replyUrl = response.headers.get('Location') || '';
169+
170+
if (!replyUrl.startsWith(settings.baseUrl)) {
171+
replyUrl = URLExt.join(settings.baseUrl, replyUrl);
172+
}
173+
const { parent_header, input_request } = await response.json();
174+
// TODO only the client sending the snippet will be prompted for the input
175+
// we can have a deadlock if its connection is lost.
176+
const panel = new Panel();
177+
panel.addClass('jp-OutputArea-child');
178+
panel.addClass('jp-OutputArea-stdin-item');
179+
180+
const prompt = new OutputPrompt();
181+
prompt.addClass('jp-OutputArea-prompt');
182+
panel.addWidget(prompt);
183+
184+
const input = new Stdin({
185+
future: Object.freeze({
186+
sendInputReply: (
187+
content: KernelMessage.IInputReply,
188+
parent_header: KernelMessage.IHeader<'input_request'>
189+
) => {
190+
ServerConnection.makeRequest(
191+
replyUrl,
192+
{
193+
method: 'POST',
194+
body: JSON.stringify({ input: content.value })
195+
},
196+
settings
197+
).catch(error => {
198+
console.error(
199+
`Failed to set input to ${JSON.stringify(content)}.`,
200+
error
201+
);
202+
});
203+
}
204+
}) as Kernel.IShellFuture,
205+
parent_header,
206+
password: input_request.password,
207+
prompt: input_request.prompt,
208+
translator
209+
});
210+
input.addClass('jp-OutputArea-output');
211+
panel.addWidget(input);
212+
213+
// Get the input node to ensure focus after updating the model upon user reply.
214+
const inputNode = input.node.getElementsByTagName('input')[0];
215+
216+
void input.value.then(value => {
217+
panel.addClass('jp-OutputArea-stdin-hiding');
218+
219+
// FIXME this is not great as the model should not be modified on the client.
220+
// Use stdin as the stream so it does not get combined with stdout.
221+
// Note: because it modifies DOM it may (will) shift focus away from the input node.
222+
cell.outputArea.model.add({
223+
output_type: 'stream',
224+
name: 'stdin',
225+
text: value + '\n'
226+
});
227+
// Refocus the input node after it lost focus due to update of the model.
228+
inputNode.focus();
229+
230+
// Keep the input in view for a little while; this (along refocusing)
231+
// ensures that we can avoid the cell editor stealing the focus, and
232+
// leading to user inadvertently modifying editor content when executing
233+
// consecutive commands in short succession.
234+
window.setTimeout(async () => {
235+
// Tack currently focused element to ensure that it remains on it
236+
// after disposal of the panel with the old input
237+
// (which modifies DOM and can lead to focus jump).
238+
const focusedElement = document.activeElement;
239+
// Dispose the old panel with no longer needed input box.
240+
panel.dispose();
241+
// Refocus the element that was focused before.
242+
if (focusedElement && focusedElement instanceof HTMLElement) {
243+
focusedElement.focus();
244+
}
245+
246+
try {
247+
const response = await requestServer(
248+
cell,
249+
url,
250+
init,
251+
settings,
252+
translator
253+
);
254+
promise.resolve(response);
255+
} catch (error) {
256+
promise.reject(error);
257+
}
258+
}, 500);
259+
});
260+
261+
cell.outputArea.layout.addWidget(panel);
262+
} else {
263+
promise.reject(await ServerConnection.ResponseError.create(response));
264+
}
265+
} else if (response.status === 202) {
266+
let redirectUrl = response.headers.get('Location') || url;
267+
268+
if (!redirectUrl.startsWith(settings.baseUrl)) {
269+
redirectUrl = URLExt.join(settings.baseUrl, redirectUrl);
270+
}
271+
272+
setTimeout(
273+
async (
274+
cell: CodeCell,
275+
url: string,
276+
init: RequestInit,
277+
settings: ServerConnection.ISettings,
278+
translator?: ITranslator,
279+
interval?: number
280+
) => {
281+
try {
282+
const response = await requestServer(
283+
cell,
284+
url,
285+
init,
286+
settings,
287+
translator,
288+
interval
289+
);
290+
promise.resolve(response);
291+
} catch (error) {
292+
promise.reject(error);
293+
}
294+
},
295+
interval,
296+
cell,
297+
redirectUrl,
298+
{ method: 'GET' },
299+
settings,
300+
translator,
301+
// Evanescent interval
302+
Math.min(MAX_POLLING_INTERVAL, interval * 2)
303+
);
304+
} else {
305+
promise.resolve(response);
306+
}
307+
})
308+
.catch(reason => {
309+
promise.reject(new ServerConnection.NetworkError(reason));
310+
});
311+
return promise.promise;
312+
}
313+
314+
export const notebookCellExecutor: JupyterFrontEndPlugin<INotebookCellExecutor> =
315+
{
316+
id: 'jupyter-server-nbmodel:notebook-cell-executor',
317+
description:
318+
'Add notebook cell executor that uses REST API instead of kernel protocol over WebSocket.',
319+
autoStart: true,
320+
provides: INotebookCellExecutor,
321+
activate: (app: JupyterFrontEnd): INotebookCellExecutor => {
322+
return new NotebookCellServerExecutor({
323+
serverSettings: app.serviceManager.serverSettings
324+
});
325+
}
326+
};

0 commit comments

Comments
 (0)