构建自定义小部件 - 电子邮件小部件#

本教程展示了如何使用 TypeScript 小部件 cookiecutter 构建简单的电子邮件小部件:

最终结果

设置开发环境#

使用miniconda安装conda#

我们建议使用miniconda来安装conda

安装说明可以在conda安装文档中找到:https://docs.conda.io/projects/conda/en/latest/user-guide/install/index.html。

创建包含依赖项的新conda环境#

接下来创建一个包含以下内容的conda环境:

  1. JupyterLab的最新版本或经典笔记本

  2. cookiecutter:用于引导自定义小部件的工具

  3. NodeJS:用于编译自定义小部件的JavaScript运行时(例如,TypeScript,CSS)

要创建环境,请执行以下命令:

conda create -n ipyemail -c conda-forge jupyterlab cookiecutter nodejs yarn python

然后使用以下命令激活环境:

conda activate ipyemail

创建新项目#

从cookiecutter初始化项目#

通常建议使用cookiecutter引导小部件。

目前有两个可用的cookiecutter项目:

在本教程中,我们将使用TypeScript cookiecutter,因为许多现有的小部件都是用TypeScript编写的。

要生成项目,请运行以下命令:

cookiecutter https://github.com/jupyter-widgets/widget-ts-cookiecutter

在提示时,按如下所示输入所需的值:

author_name []: 你的名字
author_email []: your@name.net
github_project_name []: ipyemail
github_organization_name []: 
python_package_name [ipyemail]:
npm_package_name [ipyemail]: jupyter-email
npm_package_version [0.1.0]:
project_short_description [A Custom Jupyter Widget Library]: A Custom Email Widget

更改cookiecutter创建的目录并列出文件:

cd ipyemail
ls

您应该看到一个类似以下的列表:

appveyor.yml  css   examples  ipyemail.json  MANIFEST.in   pytest.ini  readthedocs.yml  setup.cfg  src    tsconfig.json
codecov.yml   docs  ipyemail  LICENSE.txt    package.json  README.md   setupbase.py     setup.py   tests  webpack.config.js

构建并安装用于开发的小部件#

生成的项目应该已经包含一个带有本地开发小部件说明的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”字符串:

hello-world

实现小部件#

小部件框架建立在 Comm 框架(通信的简称)之上。Comm 框架是允许内核与前端发送/接收JSON消息的框架(如下所示)。

小部件层

要了解更多关于底层小部件协议的工作方式,请查看低级别小部件文档。

要创建自定义小部件,您需要在浏览器和Python内核中定义小部件。

Python Kernel#

DOMWidgetValueWidgetWidget#

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 as Widget

  • If you wanted to create a widget that displays directly in the notebook for usage with interact (like IntSlider), you should multiple inherit from both DOMWidget and ValueWidget.

  • If you wanted to create a widget that provides a value to interact but does not need to be displayed, you should inherit from only ValueWidget

_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.

Syncing mutable types

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 Views. 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:

end-result

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:

If you want to learn more about building custom widgets, you can also check out the rich ecosystem of third-party widgets: