Development is now happening in https://github.com/jtpio/jupyterlab-extension-examples with the following roadmap:
- Update to JupyterLab 1.0
- Update existing examples
- Add more examples
- Move the repo to https://github.com/jupyterlab when ready
- Introduction
- Prerequesites
- 1 Hello World: Setting up the development environment
- 2 Commands and Menus: Extending the main app
- 3 Widgets: Adding new Elements to the Main Window
- 4 Kernel Outputs: Simple Notebook-style Rendering
- 5 Buttons and Signals: Interactions Between Different Widgets
- 6 Custom Kernel Interactions: Kernel Managment and Messaging
This is a short tutorial series about JupyterLab extensions. Jupyterlab can be used as a platform to combine existing data-science components into a new powerful application that can be deployed remotely for many users. Some of the higher level components that can be used are text editors, terminals, notebooks, interactive widgets, filebrowser, renderers for different file formats that provide access to an enormous ecosystem of libraries from different languages.
Writing an extension is not particularly difficult but requires very basic knowledge of javascript and typescript and potentially Python.
Don't be scared of typescript, I never coded in typescript before I touched JupyterLab but found it easier to understand than pure javascript if you have a basic understanding of object oriented programming and types.
Writing a JupyterLab extension usually starts from a configurable template. It
can be downloded with the cookiecutter
tool and the following command:
cookiecutter https://github.com/JupyterLab/extension-cookiecutter-ts
cookiecutter
asks for some basic information that could for example be setup
like this:
author_name []: tuto
extension_name [JupyterLab_myextension]: 1_hello_world
project_short_description [A JupyterLab extension.]: minimal lab example
repository [https://github.com/my_name/JupyterLab_myextension]:
The cookiecutter creates the directory 1_hello_world
[or your extension name]
that looks like this:
1_hello_world/
├── README.md
├── package.json
├── tsconfig.json
├── src
│  └── index.ts
└── style
  └── index.css
README.md
contains some instructionspackage.json
contains information about the extension such as dependenciestsconfig.json
contains information for the typescript compilationsrc/index.ts
this contains the actual code of our extensionstyle/index.css
contains style elements that we can use
What does this extension do? You don't need a PhD in Computer Science to take a guess from the title of this section, but let's have a closer look:
We start with the file src/index.ts
. This typescript file contains the main
logic of the extension. It begins with the following import section:
import {
JupyterLab, JupyterLabPlugin
} from '@JupyterLab/application';
import '../style/index.css';
JupyterLab
is class of the main Jupyterlab application. It allows us to
access and modify some of its main components. JupyterLabPlugin
is the class
of the extension that we are building. Both classes are imported from a package
called @JupyterLab/application
. The dependency of our extension on this
package is declared in the file package.json
:
"dependencies": {
"@JupyterLab/application": "^0.16.0"
},
With this basic import setup, we can move on to construct a new instance
of the JupyterLabPlugin
class:
const extension: JupyterLabPlugin<void> = {
id: '1_hello_world',
autoStart: true,
activate: (app: JupyterLab) => {
console.log('JupyterLab extension 1_hello_world is activated!');
}
};
export default extension;
a JupyterLabPlugin contains a few attributes that are fairly self-explanatory
in the case of id
and autoStart
. The activate
attribute links to a
function (() => {}
notation) that takes one argument app
of type
JupyterLab
and then calls the console.log
function that is used to output
something into the browser console in javascript. app
is simply the main
JupyterLab application. The activate function acts as an entry point into the
extension and we will gradually extend it to access and modify functionality
through the app
object.
Our new JupyterLabPlugin
instance has to be finally exported to be visible to
JupyterLab, which is done with the line export default extension
. This brings
us to the next point. How can we plug this extension into JupyterLab?
Let's look at the README.md
file. It contains instructions how our
labextension can be installed for development:
For a development install (requires npm version 4 or later), do the following in the repository directory:
npm install
npm run build
jupyter labextension link .
Roughly the first command installs the dependencies that are specified in
package.json
. Among the dependencies are also all of the JupyterLab
components that we want to use in our project, but more about this later. The
second step runs the build script. In this step, the typescript code gets
converted to javascript using the compiler tsc
and stored in a lib
directory. Finally, we link the module to JupyterLab.
After all of these steps are done, running jupyter labextension list
should
now show something like:
local extensions:
1_hello_world: [...]/labextension_tutorial/1_hello_world
Now let's check inside of JupyterLab if it works. Run [can take a while]:
jupyter lab --watch
Our extension doesn't do much so far, it just writes something to the browser
console. So let's check if it worked. In firefox you can open the console
pressing the f12
key. You should see something like:
JupyterLab extension 1_hello_world is activated
Our extension works but it is incredibly boring. Let's modify the source code
a bit. Simply replace the activate
function with the following lines:
activate: (app: JupyterLab) => {
console.log('the main JupyterLab application:');
console.log(app);
}
to update the module, we simply need to go into the extension directory and run
npm run build
again. Since we used the --watch
option when starting
JupyterLab, we now only have to refresh the JupyterLab website in the browser
and should see in the browser console:
Object { _started: true, _pluginMap: {…}, _serviceMap: Map(28), _delegate: {…}, commands: {…}, contextMenu: {…}, shell: {…}, registerPluginErrors: [], _dirtyCount: 0, _info: {…}, … } index.js:12
This is the main application JupyterLab object and we will see how to interact with it in the next sections.
checkout how the core packages of JupyterLab are defined at https://github.com/JupyterLab/JupyterLab/tree/master/packages . Each package is structured similarly to the extension that we are writing. This modular structure makes JupyterLab very adapatable
An overview of the classes and their attributes and methods can be found in the
JupyterLab documentation. The @JupyterLab/application
module documentation is
here
and which links to the JupyterLab class.
The JupyterLabPlugin
is a type alias [a new name] for the type IPlugin
.
The definition of IPlugin
is more difficult to find because it is defined by
the phosphor.js
library that runs JupyterLab under the hood (more about this
later). Its documentation is therefore located on the phosphor.js
website.
Click here for the final extension: 1_hello_world
For the next extension you can either copy the last folder to a new one or
simply continue modifying it. In case that you want to have a new extension,
open the file package.json
and modify the package name, e.g. into
2_commands_and_menus
. The same name changes needs to be done in
src/index.ts
.
Start it with jupyter lab --watch
. In this extension, we are going to add a
command to the application command registry and expose it to the user in the
command palette. The command palette can be seen when clicking on Commands
on the left hand side of Jupyterlab. The command palette can be seen as a list
of actions that can be executed by JupyterLab. (see screenshot below).
Extensions can provide a bunch of functions to the JupyterLab command registry and then expose them to the user through the command palette or through a menu item.
Two types play a role in this: the CommandRegistry
type (documentation)
and the command palette interface ICommandPalette
that is imported with:
import {
ICommandPalette
} from '@JupyterLab/apputils';
To see how we access the applications command registry and command palette
open the file src/index.ts
.
const extension: JupyterLabPlugin<void> = {
id: '2_commands_and_menus',
autoStart: true,
requires: [ICommandPalette],
activate: (
app: JupyterLab,
palette: ICommandPalette) =>
{
const { commands } = app;
let command = 'labtutorial';
let category = 'Tutorial';
commands.addCommand(command, {
label: 'New Labtutorial',
caption: 'Open the Labtutorial',
execute: (args) => {console.log('Hey')}});
palette.addItem({command, category});
}
};
export default extension;
The CommandRegistry is an attribute of the main JupyterLab application
(variable app
in the previous section). It has an addCommand
method that
adds our own function.
The ICommandPalette
(documentation)
is passed to the activate
function as an argument (variable palette
) in
addition to the JupyterLab application (variable app
). We specify with the
property requires: [ICommandPalette],
which additional arguments we want to
inject into the activate
function in the JupyterLabPlugin. ICommandPalette
provides the method addItem
that links a palette entry to a command in the
command registry. Our new plugin code then becomes:
When this extension is build (and linked if necessary), JupyterLab looks like this:
Adding new menu items works in a similar way. The IMainMenu interface can be passed as a new argument two the activate function, but first it has to be imported. The Menu class is imported from the phosphor library on top of which JupyterLab is built, and that will be frequently encountered when developing JupyterLab extensions:
import {
IMainMenu
} from '@JupyterLab/mainmenu';
import {
Menu
} from '@phosphor/widgets';
We add the IMainMenu in the requires:
property such that it is injected into
the activate
function. The Extension is then changed to:
const extension: JupyterLabPlugin<void> = {
id: '2_commands_and_menus',
autoStart: true,
requires: [ICommandPalette, IMainMenu],
activate: (
app: JupyterLab,
palette: ICommandPalette,
mainMenu: IMainMenu) =>
{
const { commands } = app;
let command = 'labtutorial';
let category = 'Tutorial';
commands.addCommand(command, {
label: 'New Labtutorial',
caption: 'Open the Labtutorial',
execute: (args) => {console.log('Hey')}});
palette.addItem({command, category});
let tutorialMenu: Menu = new Menu({commands});
tutorialMenu.title.label = 'Tutorial';
mainMenu.addMenu(tutorialMenu, {rank: 80});
tutorialMenu.addItem({ command });
}
};
export default extension;
In this extension, we have added the dependencies JupyterLab/mainmenu and
phosphor/widgets. Before it builds, this dependencies have to be added to the
package.json
file:
"dependencies": {
"@JupyterLab/application": "^0.16.0",
"@JupyterLab/mainmenu": "*",
"@phosphor/widgets": "*"
}
we can then do
npm install
npm run build
to rebuild the application. After a browser refresh, the JupyterLab website should now show:
[ps:
for the build to run, the tsconfig.json
file might have to be updated to:
{
"compilerOptions": {
"declaration": true,
"noImplicitAny": true,
"noEmitOnError": true,
"noUnusedLocals": true,
"module": "commonjs",
"moduleResolution": "node",
"target": "ES6",
"outDir": "./lib",
"lib": ["ES6", "ES2015.Promise", "DOM"],
"types": []
},
"include": ["src/*"]
}
]
Click here for the final extension 2_commands_and_menus
Finally we are going to do some real stuff and add a new tab to JupyterLab. Visible elements such as a tab are represented by widgets in the phosphor library that is the basis of the JupyterLab application.
The base widget class can be imported with:
import {
Widget
} from '@phosphor/widgets';
A Widget can be added to the main area through the main JupyterLab
applicationapp.shell
. Inside of our previous activate
function, this looks
like this:
activate: (
app: JupyterLab,
palette: ICommandPalette,
mainMenu: IMainMenu) =>
{
const { commands, shell } = app;
let command = 'ex3:labtutorial';
let category = 'Tutorial';
commands.addCommand(command, {
label: 'Ex3 command',
caption: 'Open the Labtutorial',
execute: (args) => {
const widget = new TutorialView();
shell.addToMainArea(widget);}});
palette.addItem({command, category});
let tutorialMenu: Menu = new Menu({commands});
tutorialMenu.title.label = 'Tutorial';
mainMenu.addMenu(tutorialMenu, {rank: 80});
tutorialMenu.addItem({ command });
}
The custom widget TutorialView
is straight-forward as well:
class TutorialView extends Widget {
constructor() {
super();
this.addClass('jp-tutorial-view')
this.id = 'tutorial'
this.title.label = 'Tutorial View'
this.title.closable = true;
}
}
Note that we have used a custom css class that is defined in the file
style/index.css
as:
.jp-tutorial-view {
background-color: AliceBlue;
}
Our custom tab can be started in JupyterLab from the command palette and looks like this:
Click here for the final extension: 3_widgets
Now let's do something a little more advanced. Jupyterlab is build on top of Phosphor.js. Let's see if we can plug this phosphor example into JupyterLab.
We start by importing the Panel
widget and the DataGrid
and DataModel
classes from phosphor:
import {
Panel
} from '@phosphor/widgets';
import {
DataGrid, DataModel
} from '@phosphor/datagrid';
The Panel widget can hold several sub-widgets that are added with its
.addWidget
method. DataModel
is a class that provides the data that is
displayed by the DataGrid
widget.
With these three classes, we adapt the TutorialView
as follows:
class TutorialView extends Panel {
constructor() {
super();
this.addClass('jp-tutorial-view')
this.id = 'tutorial'
this.title.label = 'Tutorial View'
this.title.closable = true;
let model = new LargeDataModel();
let grid = new DataGrid();
grid.model = model;
this.addWidget(grid);
}
}
That's rather easy. Let's now dive into the DataModel
class that is taken
from the official phosphor.js example. The first few lines look like this:
class LargeDataModel extends DataModel {
rowCount(region: DataModel.RowRegion): number {
return region === 'body' ? 1000000000000 : 2;
}
columnCount(region: DataModel.ColumnRegion): number {
return region === 'body' ? 1000000000000 : 3;
}
While it is fairly obvious that rowCount
and columnCount
are supposed
to return some number of rows and columns, it is a little more cryptic what
the RowRegion
and the ColumnRegion
input arguments are. Let's have a
look at their definition in the phosphor.js source code:
export
type RowRegion = 'body' | 'column-header';
/**
* A type alias for the data model column regions.
*/
export
type ColumnRegion = 'body' | 'row-header';
/**
* A type alias for the data model cell regions.
*/
export
type CellRegion = 'body' | 'row-header' | 'column-header' | 'corner-header';
The meaning of these lines might be obvious for experienced users of typescript
or Haskell. The |
can be read as or. This means that the RowRegion
type is
either body
or column-header
, explaining what the rowCount
and
columnCount
functions do: They define a table with 2
header rows, with 3
index columns, with 1000000000000
rows and 1000000000000
columns.
The remaining part of the LargeDataModel class defines the data values of the datagrid. In this case it simply displays the row and column index in each cell, and adds a letter prefix in case that we are in any of the header regions:
data(region: DataModel.CellRegion, row: number, column: number): any {
if (region === 'row-header') {
return `R: ${row}, ${column}`;
}
if (region === 'column-header') {
return `C: ${row}, ${column}`;
}
if (region === 'corner-header') {
return `N: ${row}, ${column}`;
}
return `(${row}, ${column})`;
}
}
Let's see how this looks like in Jupyterlab:
Click here for the final extension: 3b_datagrid
In this extension we will see how initialize a kernel, and how to execute code
and how to display the rendered output. We use the OutputArea
class for this
purpose that Jupyterlab uses internally to render the output area under a
notebook cell or the output area in the console.
Essentially, OutputArea
will renders the data that comes as a reply to an
execute message that was sent to an underlying kernel. Under the hood, the
OutputArea
and the OutputAreaModel
classes act similar to the KernelView
and KernelModel
classes that we have defined ourselves before. We therefore
get rid of the model.ts
and widget.tsx
files and change the panel class to:
Since our extension is growing bigger, we begin by splitting our code into more managable units. Roughly we can see three larger components of our application:
- the
JupyterLabPlugin
that activates all extension components and connects them to the mainJupyterlab
application via commands, launcher, or menu items. - a Panel that combines different widgets into a single application
We split these components in the two files:
src/
├── index.ts
└── panel.ts
Let's go first through panel.ts
. This is the full Panel class that displays
starts a kernel, then sends code to it end displays the returned data with the
jupyter renderers:
export
class TutorialPanel extends StackedPanel {
constructor(manager: ServiceManager.IManager, rendermime: RenderMimeRegistry) {
super();
this.addClass(PANEL_CLASS);
this.id = 'TutorialPanel';
this.title.label = 'Tutorial View'
this.title.closable = true;
let path = './console';
this._session = new ClientSession({
manager: manager.sessions,
path,
name: 'Tutorial',
});
this._outputareamodel = new OutputAreaModel();
this._outputarea = new SimplifiedOutputArea({ model: this._outputareamodel, rendermime: rendermime });
this.addWidget(this._outputarea);
this._session.initialize();
}
dispose(): void {
this._session.dispose();
super.dispose();
}
public execute(code: string) {
SimplifiedOutputArea.execute(code, this._outputarea, this._session)
.then((msg: KernelMessage.IExecuteReplyMsg) => {console.log(msg); })
}
protected onCloseRequest(msg: Message): void {
super.onCloseRequest(msg);
this.dispose();
}
get session(): IClientSession {
return this._session;
}
private _session: ClientSession;
private _outputarea: SimplifiedOutputArea;
private _outputareamodel: OutputAreaModel;
}
The first thing that we want to focus on is the ClientSession
that is
stored in the private _session
variable:
private _session: ClientSession;
A ClientSession handles a single kernel session. The session itself (not yet the kernel) is started with these lines:
let path = './console';
this._session = new ClientSession({
manager: manager.sessions,
path,
name: 'Tutorial',
});
A kernel is initialized with this line:
this._session.initialize();
In case that a session has no predefined favourite kernel, a popup will be started that asks the user which kernel should be used. Conveniently, this can also be an already existing kernel, as we will see later.
The following three methods add functionality to cleanly dispose of the session when we close the panel, and to expose the private session variable such that other users can access it.
dispose(): void {
this._session.dispose();
super.dispose();
}
protected onCloseRequest(msg: Message): void {
super.onCloseRequest(msg);
this.dispose();
}
get session(): IClientSession {
return this._session;
}
The SimplifiedOutputArea
class is a Widget, as we have seen them before. We
can instantiate it with a new OutputAreaModel
(this is class that contains
the data that will be shown):
this._outputareamodel = new OutputAreaModel();
this._outputarea = new SimplifiedOutputArea({ model: this._outputareamodel, rendermime: rendermime });
SimplifiedOutputArea
provides the classmethod execute
that basically sends
some code to a kernel through a ClientSession and that then displays the result
in a specific SimplifiedOutputArea
instance:
public execute(code: string) {
SimplifiedOutputArea.execute(code, this._outputarea, this._session)
.then((msg: KernelMessage.IExecuteReplyMsg) => {console.log(msg); })
}
The SimplifiedOutputArea.execute
function receives at some point a response
message from the kernel which says that the code was executed (this message
does not contain the data that is displayed). When this message is received,
.then
is executed and prints this message to the console.
We just have to add the SimplifiedOutputArea
Widget to our Panel with:
this.addWidget(this._outputarea);
and we are ready to add the whole Panel to Jupyterlab.
index.ts
is responsible to initialize the extension. We only go through
the changes with respect to the last sections.
First we reorganize the extension commands into one unified namespace:
namespace CommandIDs {
export
const create = 'Ex7:create';
export
const execute = 'Ex7:execute';
}
This allows us to add commands from the command registry to the pallette and menu tab in a single call:
// add items in command palette and menu
[
CommandIDs.create,
CommandIDs.execute
].forEach(command => {
palette.addItem({ command, category });
tutorialMenu.addItem({ command });
});
Another change is that we now use the manager
to add our extension after the
other jupyter services are ready. The serviceManager can be obtained from the
main application as:
const manager = app.serviceManager;
to launch our application, we can then use:
let panel: TutorialPanel;
function createPanel() {
return manager.ready
.then(() => {
panel = new TutorialPanel(manager, rendermime);
return panel.session.ready})
.then(() => {
shell.addToMainArea(panel);
return panel});
}
Let's for example display the variable df
from a python kernel that could
contain a pandas dataframe. To do this, we just need to add a command to the
command registry in index.ts
command = CommandIDs.execute
commands.addCommand(command, {
label: 'Ex7: show dataframe',
caption: 'show dataframe',
execute: (args) => {panel.execute('df')}});
and we are ready to see it. The final extension looks like this:
Click here for the final extension: 4a_outputareas
A lot of advanced functionality in Jupyter notebooks comes in the form of jupyter widgets (ipython widgets). Jupyter widgets are elements that have one representation in a kernel and another representation in the JupyterLab frontend code. Imagine having a large dataset in the kernel that we want to examine and modify interactively. In this case, the frontend part of the widget can be changed and updates synchronously the kernel data. Many other widget types are available and can give an app-like feeling to a Jupyter notebook. These widgets are therefore ideal component of a JupyterLab extension.
We show in this extension how the ipywidget qgrid
can be integrated into
JupyterLab. As a first step, install ipywidgets
and grid
. It should work
in a similar way with any other ipywidget.
(These are the commands to install the ipywidgets with anaconda:
conda install -c conda-forge ipywidgets
conda install -c conda-forge qgrid
jupyter labextension install @jupyter-widgets/JupyterLab-manager
jupyter labextension install qgrid
)
Before continuing, test if you can (a) open a notebook, and (b) see a table when executing these commands in a cell:
import pandas as pd
import numpy as np
import qgrid
df = pd.DataFrame(np.arange(25).reshape(5, 5))
qgrid.show_grid(df)
If yes, we can check out how we can include this table in our own app. Similar
to the previous Extension, we will use the OutputArea
class to display the
widget. Only some minor adjustments have to be made to make it work.
The first thing is to understand the nature of the jupyter-widgets JupyterLab
extension (called jupyterlab-manager
). As this text is written (26/6/2018) it
is a document extension and not a general extension to JupyterLab. This means
it provides extra functionality to the notebook document type and not the the
full Jupyterlab app. The relevant lines from the jupyter-widgets source code
that show how it registers its renderer with Jupyterlab are the following:
export
class NBWidgetExtension implements INBWidgetExtension {
/**
* Create a new extension object.
*/
createNew(nb: NotebookPanel, context: DocumentRegistry.IContext<INotebookModel>): IDisposable {
let wManager = new WidgetManager(context, nb.rendermime);
this._registry.forEach(data => wManager.register(data));
nb.rendermime.addFactory({
safe: false,
mimeTypes: [WIDGET_MIMETYPE],
createRenderer: (options) => new WidgetRenderer(options, wManager)
}, 0);
return new DisposableDelegate(() => {
if (nb.rendermime) {
nb.rendermime.removeMimeType(WIDGET_MIMETYPE);
}
wManager.dispose();
});
}
/**
* Register a widget module.
*/
registerWidget(data: base.IWidgetRegistryData) {
this._registry.push(data);
}
private _registry: base.IWidgetRegistryData[] = [];
}
The createNew
method of NBWidgetExtension
takes a NotebookPanel
as input
argument and then adds a custom mime renderer with the command
nb.rendermime.addFactory
to it. The widget renderer (or rather RenderFactory)
is linked to the WidgetManager
that stores the underlying data of the
jupyter-widgets. Unfortunately, this means that we have to access
jupyter-widgets through a notebook because its renderer and WidgetManager are
attached to it.
To access the current notebook, we can use an INotebookTracker
in the
plugin's activate function:
const extension: JupyterLabPlugin<void> = {
id: '6_ipywidgets',
autoStart: true,
requires: [ICommandPalette, INotebookTracker, ILauncher, IMainMenu],
activate: activate
};
function activate(
app: JupyterLab,
palette: ICommandPalette,
tracker: INotebookTracker,
launcher: ILauncher,
mainMenu: IMainMenu)
{
[...]
We then pass the rendermime
registry of the notebook (the one that has the
jupyter-widgets renderer added) to our panel:
function createPanel() {
let current = tracker.currentWidget;
console.log(current.rendermime);
return manager.ready
.then(() => {
panel = new TutorialPanel(manager, current.rendermime);
return panel.session.ready})
.then(() => {
shell.addToMainArea(panel);
return panel});
}
Finally we add a command to the registry that executes the code widget
that
displays the variable widget
in which we are going to store the qgrid widget:
let code = 'widget'
command = CommandIDs.execute
commands.addCommand(command, {
label: 'Ex8: show widget',
caption: 'show ipython widget',
execute: () => {panel.execute(code)}});
To render the Output we have to allow the OutputAreaModel
to use non-default
mime types, which can be done like this:
this._outputareamodel = new OutputAreaModel({ trusted: true });
The final output looks is demonstrated in the gif below. We also show that we can attach a console to a kernel, that shows all executed commands, including the one that we send from our Extension.
Click here for the final extension: 4b_jupyterwidgets
In this extension, we are going to add some simple buttons to the widget that
trigger the panel to print something to the console. Communication between
different components of JupyterLab are a key ingredient in building an
extension. Jupyterlab's phosphor engine uses the ISignal
interface and the
Signal
class that implements this interface for communication
(documentation).
The basic concept is the following: A widget, in our case the one that contains
some visual elements such as a button, defines a signal and exposes it to other
widgets, as this _stateChanged
signal:
get stateChanged(): ISignal<TutorialView, void> {
return this._stateChanged;
}
private _stateChanged = new Signal<TutorialView, void>(this);
Another widget, in our case the panel that boxes several different widgets,
subscribes to the stateChanged
signal and links some function to it:
[...].stateChanged.connect(() => { console.log('changed'); });
The function is executed when the signal is triggered with
_stateChanged.emit(void 0)
Let's see how we can implement this ...
We start with a file called src/widget.tsx
. The tsx
extension allows to use
XML-like syntax with the tag notation <>
to represent some visual elements
(note that you might have to add a line: "jsx": "react",
to the
tsconfig.json
file).
widget.tsx
contains one major class TutorialView
that extends the
VDomRendered
class that is provided by Jupyterlab. VDomRenderer
defines a
render()
method that defines some html elements (react) such as a button.
TutorialView
further contains a private variable stateChanged
of type
Signal
. A signal object can be triggered and then emits an actual message.
Other Widgets can subscribe to such a signal and react when a message is
emitted. We configure one of the buttons onClick
event to trigger the
stateChangedsignal with
_stateChanged.emit(void 0)`:
export
class TutorialView extends VDomRenderer<any> {
constructor() {
super();
this.id = `TutorialVDOM`
}
protected render(): React.ReactElement<any>[] {
const elements: React.ReactElement<any>[] = [];
elements.push(
<button
key='header-thread'
className="jp-tutorial-button"
onClick={() => {this._stateChanged.emit(void 0)}}>
Clickme
</button>
);
return elements;
}
get stateChanged(): ISignal<TutorialView, void> {
return this._stateChanged;
}
private _stateChanged = new Signal<TutorialView, void>(this);
}
The panel.ts
class defines an extension panel that displays the
TutorialView
widget and that subscribes to its stateChanged
signal.
Subscription to a signal is done using the connect
method of the
stateChanged
attribute. It registers a function (in this case
() => { console.log('changed'); }
that is triggered when the signal is
emitted:
export
class TutorialPanel extends StackedPanel {
constructor() {
super();
this.addClass(PANEL_CLASS);
this.id = 'TutorialPanel';
this.title.label = 'Tutorial View'
this.title.closable = true;
this.tutorial = new TutorialView();
this.addWidget(this.tutorial);
this.tutorial.stateChanged.connect(() => { console.log('changed'); });
}
private tutorial: TutorialView;
}
The final extension writes a little changed
text to the browser console when
a big red button is clicked. It is not very spectacular but the signaling is
conceptualy important for building extensions. It looks like this:
Click here for the final extension: 5_signals_and_buttons
One of the main features of JupyterLab is the possibility to manage and interact underlying compute kernels. In this section, we explore how to start a kernel and execute a simple command on it.
In terms of organization of this app, we want to have these components:
index.ts
: a JupyterLabPlugin that initializes the plugin and registers commands, menu tabs and a launcherpanel.ts
: a panel class that is responsible to initialize and hold the kernel session, widgets and modelsmodel.ts
: a KernelModel class that is responsible to execute code on the kernel and to store the execution resultwidget.tsx
: a KernelView class that is responsible to provide visual elements that trigger the kernel model and display its results
The KernelView
displays the KernelModel
with some react html elements and
needs to get updated when KernelModel
changes state, i.e. retrieves a new
execution result. Jupyterlab provides a two class model for such classes,
a VDomRendered
that has a link to a VDomModel
and a render
function.
The VDomRendered
listens to a stateChanged
signal that is defined by the
VDomModel
. Whenever the stateChanged
signal is emitted, the
VDomRendered
calls its render
function again and updates the html elements
according to the new state of the model.
Jupyterlab provides a class ClientSession
(documentation)
that manages a single kernel session. Here are the lines that we need to start
a kernel with it:
this._session = new ClientSession({
manager: manager.sessions,
path,
name: 'Tutorial',
});
this._session.initialize();
well, that's short, isn't it? We have already seen the manager
class that is
provided directly by the main JupyterLab application. path
is a link to the
path under which the console is opened (?).
With these lines, we can extend the panel widget from 7_signals_and_buttons to intialize a
kernel. In addition, we will initialize a KernelModel
class in it and
overwrite the dispose
and onCloseRequest
methods of the StackedPanel
(documentation)
to free the kernel session resources if the panel is closed. The whole adapted
panel class looks like this:
export
class TutorialPanel extends StackedPanel {
constructor(manager: ServiceManager.IManager) {
super();
this.addClass(PANEL_CLASS);
this.id = 'TutorialPanel';
this.title.label = 'Tutorial View'
this.title.closable = true;
let path = './console';
this._session = new ClientSession({
manager: manager.sessions,
path,
name: 'Tutorial',
});
this._model = new KernelModel(this._session);
this._tutorial = new TutorialView(this._model);
this.addWidget(this._tutorial);
this._session.initialize();
}
dispose(): void {
this._session.dispose();
super.dispose();
}
protected onCloseRequest(msg: Message): void {
super.onCloseRequest(msg);
this.dispose();
}
get session(): IClientSession {
return this._session;
}
private _model: KernelModel;
private _session: ClientSession;
private _tutorial: TutorialView;
}
Once a kernel is initialized and ready, code can be executed on it through
the ClientSession
class with the following snippet:
this.future = this._session.kernel.requestExecute({ code });
Without getting too much into the details of what this future
is, let's think
about it as an object that can receive some messages from the kernel as an
answer on our execution request (see jupyter messaging).
One of these messages contains the data of the execution result. It is
published on a channel called IOPub
and can be identified by the message
types execute_result
, display_data
and update_display_data
.
Once such a message is received by the future
object, it can trigger an
action. In our case, we just store this message in this._output
and then
emit a stateChanged
signal. As we have explained above, our KernelModel
is
a VDomModel
that provides this stateChanged
signal that can be used by a
VDomRendered
. It is implemented as follows:
export
class KernelModel extends VDomModel {
constructor(session: IClientSession) {
super();
this._session = session;
}
public execute(code: string) {
this.future = this._session.kernel.requestExecute({ code });
}
private _onIOPub = (msg: KernelMessage.IIOPubMessage) => {
let msgType = msg.header.msg_type;
switch (msgType) {
case 'execute_result':
case 'display_data':
case 'update_display_data':
this._output = msg.content as nbformat.IOutput;
console.log(this._output);
this.stateChanged.emit(undefined);
default:
break;
}
return true
}
get output(): nbformat.IOutput {
return this._output;
}
get future(): Kernel.IFuture {
return this._future;
}
set future(value: Kernel.IFuture) {
this._future = value;
value.onIOPub = this._onIOPub;
}
private _output: nbformat.IOutput = null;
private _future: Kernel.IFuture = null;
private _session: IClientSession;
}
The only remaining thing left is to connect a View to the Model. We have
already seen the TutorialView
before. To trigger the render
function of a
VDomRendered
on a stateChanged
signal, we just need to add our VDomModel
to this.model
in the constructor. We can then connect a button to
this.model.execute
and a text field to this.model.output
and our extension
is ready:
export
class KernelView extends VDomRenderer<any> {
constructor(model: KernelModel) {
super();
this.id = `TutorialVDOM`
this.model = model
}
protected render(): React.ReactElement<any>[] {
console.log('render');
const elements: React.ReactElement<any>[] = [];
elements.push(
<button key='header-thread'
className="jp-tutorial-button"
onClick={() => {this.model.execute('3+5')}}>
Compute 3+5</button>,
<span key='output field'>{JSON.stringify(this.model.output)}</span>
);
return elements;
}
}
Well that's nice, the basics are clear, but what about this weird output object? In the next extensions, we will explore how we can reuse some jupyter components to make things look nicer...