构建自定义小部件 - 电子邮件小部件#
本教程展示了如何使用 TypeScript 小部件 cookiecutter
构建简单的电子邮件小部件:
设置开发环境#
使用miniconda安装conda#
我们建议使用miniconda
来安装conda
。
安装说明可以在conda安装文档中找到:https://docs.conda.io/projects/conda/en/latest/user-guide/install/index.html。
创建包含依赖项的新conda环境#
接下来创建一个包含以下内容的conda环境:
JupyterLab的最新版本或经典笔记本
cookiecutter:用于引导自定义小部件的工具
NodeJS:用于编译自定义小部件的JavaScript运行时(例如,TypeScript,CSS)
要创建环境,请执行以下命令:
conda create -n ipyemail -c conda-forge jupyterlab cookiecutter nodejs yarn python
然后使用以下命令激活环境:
conda activate ipyemail
创建新项目#
构建并安装用于开发的小部件#
生成的项目应该已经包含一个带有本地开发小部件说明的README.md
文件。
由于小部件包含Python部分,因此需要以可编辑模式安装包:
python -m pip install -e .
您还需要启用小部件前端扩展。
如果您使用的是JupyterLab 3.x:
# 将开发版本的扩展与JupyterLab链接
jupyter labextension develop . --overwrite
# 在更改后自动重建小部件的TypeScript源代码
yarn build
还可以使用watch
脚本在有新更改时自动重建小部件:
# 在一个终端中监视源目录,在需要时自动重建
yarn watch
如果您使用的是经典笔记本:
jupyter nbextension install --sys-prefix --symlink --overwrite --py ipyemail
jupyter nbextension enable --sys-prefix --py ipyemail
测试安装#
此时,您应该能够打开一个笔记本并创建一个新的ExampleWidget
。
要测试它,请在终端中执行以下操作:
# 如果您使用的是经典笔记本
jupyter notebook
# 如果您使用的是JupyterLab
jupyter lab
然后打开examples/introduction.ipynb
。
默认情况下,小部件显示带有彩色背景的“Hello World”字符串:
实现小部件#
小部件框架建立在 Comm 框架(通信的简称)之上。Comm 框架是允许内核与前端发送/接收JSON消息的框架(如下所示)。
要了解更多关于底层小部件协议的工作方式,请查看低级别小部件文档。
要创建自定义小部件,您需要在浏览器和Python内核中定义小部件。
Python Kernel#
DOMWidget
、ValueWidget
和 Widget
#
To define a widget, you must inherit from the DOMWidget
, ValueWidget
, or Widget
base class. If you intend for your widget to be displayed, you'll want to inherit from DOMWidget
. If you intend for your widget to be used as an input for interact, you'll want to inherit from ValueWidget
. Your widget should inherit from ValueWidget
if it has a single obvious output (for example, the output of an IntSlider
is clearly the current value of the slider).
Both the DOMWidget
and ValueWidget
classes inherit from the Widget
class. The Widget
class is useful for cases in which the widget is not meant to be displayed directly in the notebook, but instead as a child of another rendering environment. Here are some examples:
If you wanted to create a three.js widget (three.js is a popular WebGL library), you would implement the rendering window as a
DOMWidget
and any 3D objects or lights meant to be rendered in that window asWidget
If you wanted to create a widget that displays directly in the notebook for usage with
interact
(likeIntSlider
), you should multiple inherit from bothDOMWidget
andValueWidget
.If you wanted to create a widget that provides a value to
interact
but does not need to be displayed, you should inherit from onlyValueWidget
_view_name#
Inheriting from the DOMWidget does not tell the widget framework what front end widget to associate with your back end widget.
Instead, you must tell it yourself by defining specially named trait attributes, _view_name
, _view_module
, and _view_module_version
(as seen below) and optionally _model_name
and _model_module
.
First let's rename ipyemail/example.py
to ipyemail/widget.py
.
In ipyemail/widget.py
, replace the example code with the following:
from ipywidgets import DOMWidget, ValueWidget, register
from traitlets import Unicode, Bool, validate, TraitError
from ._frontend import module_name, module_version
@register
class Email(DOMWidget, ValueWidget):
_model_name = Unicode('EmailModel').tag(sync=True)
_model_module = Unicode(module_name).tag(sync=True)
_model_module_version = Unicode(module_version).tag(sync=True)
_view_name = Unicode('EmailView').tag(sync=True)
_view_module = Unicode(module_name).tag(sync=True)
_view_module_version = Unicode(module_version).tag(sync=True)
value = Unicode('example@example.com').tag(sync=True)
In ipyemail/__init__.py
, change the import from:
from .example import ExampleWidget
To:
from .widget import Email
sync=True traitlets#
Traitlets is an IPython library for defining type-safe properties on configurable objects. For this tutorial you do not need to worry about the configurable piece of the traitlets machinery. The sync=True
keyword argument tells the widget framework to handle synchronizing that value to the browser. Without sync=True
, attributes of the widget won't be synchronized with the front-end.
Please keep in mind that mutable types will not necessarily be synced when they are modified. For example appending an element to a list
will not cause the changes to sync. Instead a new list must be created and assigned to the trait for the changes to be synced.
An alternative would be to use a third-party library such as spectate, which tracks changes to mutable data types.
Other traitlet types#
Unicode, used for _view_name
, is not the only Traitlet type, there are many more some of which are listed below:
Any
Bool
Bytes
CBool
CBytes
CComplex
CFloat
CInt
CLong
CRegExp
CUnicode
CaselessStrEnum
Complex
Dict
DottedObjectName
Enum
Float
FunctionType
Instance
InstanceType
Int
List
Long
Set
TCPAddress
Tuple
Type
Unicode
Union
Not all of these traitlets can be synchronized across the network, only the JSON-able traits and Widget instances will be synchronized.
Front end (TypeScript)#
Models and views#
The IPython widget framework front end relies heavily on Backbone.js. Backbone.js is an MVC (model view controller) framework. Widgets defined in the back end are automatically synchronized with Backbone.js Model
in the front end. Each front end Model
handles the widget data and state, and can have any number of associate View
s. In the context of a widget the Views
are what render objects for the user to interact with, and the Model handles communication with the Python objects.
On the first state push from python the synced traitlets are added automatically. The _view_name
trait that you defined earlier is used by the widget framework to create the corresponding Backbone.js view and link that view to the model.
The TypeScript cookiecutter generates a file src/widget.ts
. Open the file and rename ExampleModel
to EmailModel
and ExampleView
to EmailView
:
export class EmailModel extends DOMWidgetModel {
defaults() {
return {...super.defaults(),
_model_name: EmailModel.model_name,
_model_module: EmailModel.model_module,
_model_module_version: EmailModel.model_module_version,
_view_name: EmailModel.view_name,
_view_module: EmailModel.view_module,
_view_module_version: EmailModel.view_module_version,
value : 'Hello World'
};
}
static serializers: ISerializers = {
...DOMWidgetModel.serializers,
// Add any extra serializers here
}
static model_name = 'EmailModel';
static model_module = MODULE_NAME;
static model_module_version = MODULE_VERSION;
static view_name = 'EmailView';
static view_module = MODULE_NAME;
static view_module_version = MODULE_VERSION;
}
export class EmailView extends DOMWidgetView {
render() {
this.el.classList.add('custom-widget');
this.value_changed();
this.model.on('change:value', this.value_changed, this);
}
value_changed() {
this.el.textContent = this.model.get('value');
}
}
Render method#
Now, override the base render
method of the view to define custom rendering logic.
A handle to the widget's default DOM element can be acquired via this.el
. The el
property is the DOM element associated with the view.
In src/widget.ts
, define the _emailInput
attribute:
export class EmailView extends DOMWidgetView {
private _emailInput: HTMLInputElement;
render() {
// .....
}
// .....
}
Then, add the following logic for the render
method:
render() {
this._emailInput = document.createElement('input');
this._emailInput.type = 'email';
this._emailInput.value = 'example@example.com';
this._emailInput.disabled = true;
this.el.appendChild(this._emailInput);
this.el.classList.add('custom-widget');
this.value_changed();
this.model.on('change:value', this.value_changed, this);
},
Test#
First, run the following command to recreate the frontend bundle:
npm run build
If you use JupyterLab, you might want to use jlpm
as the npm client. jlpm
uses yarn
under the hood as the package manager. The main difference compared to npm
is that jlpm
will generate a yarn.lock
file for the dependencies, instead of package-lock.json
. With jlpm
the command is:
jlpm build
After reloading the page, you should be able to display your widget just like any other widget now:
from ipyemail import Email
Email()
Making the widget stateful#
There is not much that you can do with the above example that you can't do with the IPython display framework. To change this, you will make the widget stateful. Instead of displaying a static "example@example.com" email address, it will display an address set by the back end. First you need to add a traitlet in the back end. Use the name of value
to stay consistent with the rest of the widget framework and to allow your widget to be used with interact.
We want to be able to avoid the user to write an invalid email address, so we need a validator using traitlets.
from ipywidgets import DOMWidget, ValueWidget, register
from traitlets import Unicode, Bool, validate, TraitError
from ._frontend import module_name, module_version
@register
class Email(DOMWidget, ValueWidget):
_model_name = Unicode('EmailModel').tag(sync=True)
_model_module = Unicode(module_name).tag(sync=True)
_model_module_version = Unicode(module_version).tag(sync=True)
_view_name = Unicode('EmailView').tag(sync=True)
_view_module = Unicode(module_name).tag(sync=True)
_view_module_version = Unicode(module_version).tag(sync=True)
value = Unicode('example@example.com').tag(sync=True)
disabled = Bool(False, help="Enable or disable user changes.").tag(sync=True)
# Basic validator for the email value
@validate('value')
def _valid_value(self, proposal):
if proposal['value'].count("@") != 1:
raise TraitError('Invalid email value: it must contain an "@" character')
if proposal['value'].count(".") == 0:
raise TraitError('Invalid email value: it must contain at least one "." character')
return proposal['value']
Accessing the model from the view#
To access the model associated with a view instance, use the model
property of the view. get
and set
methods are used to interact with the Backbone model. get
is trivial, however you have to be careful when using set
. After calling the model set
you need call the view's touch
method. This associates the set
operation with a particular view so output will be routed to the correct cell. The model also has an on
method, which allows you to listen to events triggered by the model (like value changes).
Rendering model contents#
By replacing the string literal with a call to model.get
, the view will now display the value of the back end upon display. However, it will not update itself to a new value when the value changes.
export class EmailView extends DOMWidgetView {
render() {
this._emailInput = document.createElement('input');
this._emailInput.type = 'email';
this._emailInput.value = this.model.get('value');
this._emailInput.disabled = this.model.get('disabled');
this.el.appendChild(this._emailInput);
}
private _emailInput: HTMLInputElement;
}
Dynamic updates#
To get the view to update itself dynamically, register a function to update the view's value when the model's value
property changes. This can be done using the model.on
method. The on
method takes three parameters, an event name, callback handle, and callback context. The Backbone event named change
will fire whenever the model changes. By appending :value
to it, you tell Backbone to only listen to the change event of the value
property (as seen below).
export class EmailView extends DOMWidgetView {
render() {
this._emailInput = document.createElement('input');
this._emailInput.type = 'email';
this._emailInput.value = this.model.get('value');
this._emailInput.disabled = this.model.get('disabled');
this.el.appendChild(this._emailInput);
// Python -> JavaScript update
this.model.on('change:value', this._onValueChanged, this);
this.model.on('change:disabled', this._onDisabledChanged, this);
}
private _onValueChanged() {
this._emailInput.value = this.model.get('value');
}
private _onDisabledChanged() {
this._emailInput.disabled = this.model.get('disabled');
}
private _emailInput: HTMLInputElement;
}
This allows us to update the value from the Python kernel to the views. Now to get the value updated from the front-end to the Python kernel (when the input is not disabled) we set the value on the frontend model using model.set
and then sync the frontend model with the Python object using model.save_changes
.
export class EmailView extends DOMWidgetView {
render() {
this._emailInput = document.createElement('input');
this._emailInput.type = 'email';
this._emailInput.value = this.model.get('value');
this._emailInput.disabled = this.model.get('disabled');
this.el.appendChild(this._emailInput);
// Python -> JavaScript update
this.model.on('change:value', this._onValueChanged, this);
this.model.on('change:disabled', this._onDisabledChanged, this);
// JavaScript -> Python update
this._emailInput.onchange = this._onInputChanged.bind(this);
}
private _onValueChanged() {
this._emailInput.value = this.model.get('value');
}
private _onDisabledChanged() {
this._emailInput.disabled = this.model.get('disabled');
}
private _onInputChanged() {
this.model.set('value', this._emailInput.value);
this.model.save_changes();
}
private _emailInput: HTMLInputElement;
}
Test#
To instantiate a new widget:
email = Email(value='john.doe@domain.com', disabled=False)
email
To get the value of the widget:
email.value
To set the value of the widget:
email.value = 'jane.doe@domain.com'
The end result should look like the following:
Passing URLs#
In the example above we have seen how to pass simple unicode strings to a HTML input element. However,
certain HTML elements, like e.g. <img/>
, <iframe/>
or <script/>
require URLs as input. Consider
a widget embedding an <iframe/>
. The widget has a src
property that is connected to the src
attribute of the <iframe/>
. It is the ipywidget version of the built-in IPython.display.IFrame(...)
.
Like the built-in we'd like to support two forms:
from ipyiframe import IFrame
remote_url = IFrame(src='https://jupyter.org') # full HTTP URL
local_file = IFrame(src='./local_file.html') # local file
Note, that the second form is a path relative to the notebook file. Using this string as the src
attribute of the <iframe/>
is not going to work, because the browser will interpret it as a relative
URL, relative to the browsers address bar. To transform the relative path into a valid file URL we use
the utility funtion resolveUrl(...)
in our javascript view class:
export class IFrameView extends DOMWidgetView {
render() {
this.$iframe = document.createElement('iframe');
this.el.appendChild(this.$iframe);
this.src_changed();
this.model.on('change:src', this.src_changed, this);
}
src_changed() {
const url = this.model.get('src');
this.model.widget_manager.resolveUrl(url).then(resolvedUrl => {
this.$iframe.src = resolvedUrl;
});
}
}
Invoking this.model.widget_manager.resolveUrl(...)
returns a promise that resolves to the correct URL.
Learn more#
As we have seen in this tutorial, starting from a cookiecutter project is really useful to quickly prototype a custom widget.
Two cookiecutter projects are currently available:
widget-ts-cookiecutter: To create a custom widget in TypeScript
widget-cookiecutter: To create a custom widget in JavaScript
If you want to learn more about building custom widgets, you can also check out the rich ecosystem of third-party widgets: