瀏覽代碼

Merge pull request #4 from wang0618/dev

Merge dev
WangWeimin 5 年之前
父節點
當前提交
1937b6a36c

+ 30 - 0
.github/workflows/lint.yml

@@ -0,0 +1,30 @@
+# This workflow will install Python dependencies and lint with a variety of Python versions
+
+name: Python lint
+
+on: [push, pull_request]
+
+jobs:
+  lint:
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        python-version: [3.5, 3.6, 3.7, 3.8]
+
+    steps:
+      - uses: actions/checkout@v2
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v1
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install -r requirements.txt
+      - name: Lint with flake8
+        run: |
+          pip install flake8
+          # stop the build if there are Python syntax errors or undefined names
+          flake8 pywebio --count --select=E9,F63,F7,F82 --show-source --statistics
+          # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
+          flake8 pywebio --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics

+ 26 - 0
.github/workflows/test.yml

@@ -0,0 +1,26 @@
+name: Tests
+on: [push, pull_request]
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@master
+      - name: Set up Python 3.7
+        uses: actions/setup-python@v1
+        with:
+          python-version: 3.7
+      - name: Install JS deps
+        run: npm install -D @percy/agent
+      - name: Install Python deps
+        run: pip3 install -e ".[dev, flask]"
+      - name: Percy Test
+        uses: percy/exec-action@v0.2.0
+        with:
+          command: "test/run_all.sh"
+        env:
+          PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}
+      - name: Upload Codecov Report
+        run: |
+          cd test
+          bash <(curl -s https://codecov.io/bash)

+ 145 - 0
README.md

@@ -0,0 +1,145 @@
+<h1 align="center">PyWebIO</h1>
+<p align="center">
+    <em>Write web app in script way.</em>
+</p>
+<p align="center">
+    <a href="https://percy.io/pywebio/pywebio">
+        <img src="https://percy.io/static/images/percy-badge.svg" alt="Percy visual test">
+    </a>
+    <a href="https://codecov.io/gh/wang0618/PyWebIO">
+        <img src="https://codecov.io/gh/wang0618/PyWebIO/branch/dev/graph/badge.svg" />
+    </a>
+    <a href="https://pywebio.readthedocs.io/zh_CN/latest/?badge=latest">
+        <img src="https://readthedocs.org/projects/pywebio/badge/?version=latest" alt="Documentation Status">
+    </a>
+    <a href="https://badge.fury.io/py/PyWebIO">
+        <img src="https://badge.fury.io/py/PyWebIO.svg" alt="Package version">
+    </a>
+    <a href="https://pypi.org/project/PyWebIO/">
+        <img src="https://img.shields.io/pypi/pyversions/PyWebIO.svg?colorB=brightgreen" alt="Python Version">
+    </a>
+    <a href="https://github.com/wang0618/PyWebIO/blob/master/LICENSE">
+        <img src="https://img.shields.io/github/license/wang0618/PyWebIO" alt="License">
+    </a>
+</p>
+
+PyWebIO是一个用于在浏览器上获取输入和进行输出的工具库。能够将原有的通过终端交互的脚本快速服务化,供其他人在网络上通过浏览器访问使用;
+PyWebIO还可以方便地整合进现有的Web服务,让你不需要编写Html和JS代码,就可以构建出具有良好可用性的Web程序。
+
+特点:
+
+- 使用同步而不是基于回调的方式获取输入,无需在各个步骤之间保存状态,使用更方便
+- 代码侵入性小,对于旧脚本代码仅需修改输入输出逻辑
+- 支持多用户与并发请求
+- 支持结合第三方库实现数据可视化
+- 支持整合到现有的Web服务,目前支持与Tornado和Flask的集成
+- 同时支持基于线程的执行模型和基于协程的执行模型
+
+
+## Install
+
+PyPi安装:
+
+```bash
+pip3 install -U pywebio
+```
+
+目前PyWebIO处于快速开发迭代中,PyPi上的包更新可能滞后,建议使用源码安装:
+
+```bash
+pip3 install -U https://code.aliyun.com/wang0618/pywebio/repository/archive.zip
+```
+
+**系统要求**: PyWebIO要求 Python 版本在 3.5.2 及以上
+
+## Quick start
+
+**Hello, world**
+
+这是一个使用PyWebIO计算 [BMI指数](https://en.wikipedia.org/wiki/Body_mass_index>) 的脚本:
+
+```python
+from pywebio.input import input, FLOAT
+from pywebio.output import put_text
+
+def bmi():
+    height = input("请输入你的身高(cm):", type=FLOAT)
+    weight = input("请输入你的体重(kg):", type=FLOAT)
+
+    BMI = weight / (height / 100) ** 2
+
+    top_status = [(14.9, '极瘦'), (18.4, '偏瘦'),
+                  (22.9, '正常'), (27.5, '过重'),
+                  (40.0, '肥胖'), (float('inf'), '非常肥胖')]
+
+    for top, status in top_status:
+        if BMI <= top:
+            put_text('你的 BMI 值: %.1f,身体状态:%s' % (BMI, status))
+            break
+
+if __name__ == '__main__':
+    bmi()
+```
+
+
+如果没有使用PywWebIO,这只是一个非常简单的脚本,而通过使用PywWebIO提供的输入输出函数,你可以在浏览器中与代码进行交互:
+
+<p align="center">
+    <a href="https://pywebio.herokuapp.com/">
+        <img src="https://raw.githubusercontent.com/wang0618/PyWebIO/master/docs/assets/demo.gif" alt="PyWebIO demo"/>
+    </a>
+</p>
+
+**向外提供服务**
+
+上文对使用PyWebIO进行改造的程序,运行模式还是脚本,程序计算完毕后立刻退出。可以使用 [`pywebio.start_server()`](https://pywebio.readthedocs.io/zh_CN/latest/platform.html#pywebio.platform.start_server) 将 `bmi()` 函数作为Web服务提供:
+
+```python
+from pywebio import start_server
+from pywebio.input import input, FLOAT
+from pywebio.output import put_text
+
+def bmi():
+    ...  # bmi() 函数内容不变
+
+if __name__ == '__main__':
+    start_server(bmi)
+```
+
+
+
+**与现有Web框架整合**
+
+仅需在现有的Tornado应用中加入加入两个 `RequestHandler` ,就可以将使用PyWebIO编写的函数整合进Tornado应用中
+
+```python
+import tornado.ioloop
+import tornado.web
+from pywebio.platform.tornado import webio_handler
+from pywebio import STATIC_PATH
+
+class MainHandler(tornado.web.RequestHandler):
+    def get(self):
+        self.write("Hello, world")
+
+if __name__ == "__main__":
+    application = tornado.web.Application([
+        (r"/", MainHandler),
+        (r"/bmi/io", webio_handler(bmi)),  # bmi 即为上文计算BMI指数的函数
+        (r"/bmi/(.*)", tornado.web.StaticFileHandler, {"path": STATIC_PATH, 'default_filename': 'index.html'})
+    ])
+    application.listen(port=80, address='localhost')
+    tornado.ioloop.IOLoop.current().start()
+```
+
+在 `http://localhost/bmi/` 页面上就可以计算BMI了
+
+## Demos
+
+ - [数据可视化demo](http://pywebio-charts.wangweimin.site/) : 使用 plotly、pyecharts 等库创建图表
+ - [其他demo](https://pywebio.herokuapp.com/) : 包含PyWebIO基本输入输出演示和使用PyWebIO编写的小应用
+
+## Document
+
+使用手册和实现文档见 [https://pywebio.readthedocs.io](https://pywebio.readthedocs.io)
+

+ 0 - 107
README.rst

@@ -1,107 +0,0 @@
-PyWebIO
-==================
-
-PyWebIO是一个用于在浏览器上获取输入和进行输出的工具库。能够将原有的通过终端交互的脚本快速服务化,供其他人在网络上通过浏览器访问使用;
-PyWebIO还可以方便地整合进现有的Web服务,让你不需要编写Html和JS代码,就可以构建出具有良好可用性的Web程序。
-
-特点:
-
-- 使用同步而不是基于回调的方式获取输入,无需在各个步骤之间保存状态,使用更方便
-- 代码侵入性小,对于旧脚本代码仅需修改输入输出逻辑
-- 支持多用户与并发请求
-- 支持整合到现有的Web服务,目前支持与Tornado和Flask的集成
-- 同时支持基于线程的执行模型和基于协程的执行模型
-
-Install
-------------
-
-Pypi源安装::
-
-   pip3 install -U pywebio
-
-目前PyWebIO处于快速开发迭代中,Pypi上的包更新可能滞后,建议使用源码安装::
-
-    pip3 install -U https://code.aliyun.com/wang0618/pywebio/repository/archive.zip
-
-**系统要求**: PyWebIO要求 Python 版本在 3.5.2 及以上
-
-Quick start
-------------
-
-**Hello, world**
-
-这是一个使用PywWebIO计算 `BMI指数 <https://en.wikipedia.org/wiki/Body_mass_index>`_ 的脚本:
-
-.. code-block:: python
-
-    from pywebio.input import input, FLOAT
-    from pywebio.output import put_text
-
-    def bmi():
-        height = input("请输入你的身高(cm):", type=FLOAT)
-        weight = input("请输入你的体重(kg):", type=FLOAT)
-
-        BMI = weight / (height / 100) ** 2
-
-        top_status = [(14.9, '极瘦'), (18.4, '偏瘦'),
-                      (22.9, '正常'), (27.5, '过重'),
-                      (40.0, '肥胖'), (float('inf'), '非常肥胖')]
-
-        for top, status in top_status:
-            if BMI <= top:
-                put_text('你的 BMI 值: %.1f,身体状态:%s' % (BMI, status))
-                break
-
-    if __name__ == '__main__':
-        bmi()
-
-如果没有使用PywWebIO,这只是一个非常简单的脚本,而通过使用PywWebIO提供的输入输出函数,你可以在浏览器中与代码进行交互:
-
-.. image:: /docs/assets/demo.gif
-
-**向外提供服务**
-
-上文对使用PyWebIO进行改造的程序,运行模式还是脚本,程序计算完毕后立刻退出。可以使用 `pywebio.start_server <https://pywebio.readthedocs.io/zh_CN/latest/server.html#pywebio.platform.start_server>`_ 将 ``bmi()`` 函数作为Web服务提供:
-
-.. code-block:: python
-
-    from pywebio import start_server
-    from pywebio.input import input
-    from pywebio.output import put_text
-
-    def bmi():
-        # same as above code
-
-    if __name__ == '__main__':
-        start_server(bmi)
-
-**与现有Web框架整合**
-
-仅需在现有的Tornado应用中加入加入两个 ``RequestHandler`` ,就可以将使用PyWebIO编写的函数整合进 ``Tornado`` 应用中
-
-.. code-block:: python
-
-    import tornado.ioloop
-    import tornado.web
-    from pywebio.platform.tornado import webio_handler
-    from pywebio import STATIC_PATH
-
-    class MainHandler(tornado.web.RequestHandler):
-        def get(self):
-            self.write("Hello, world")
-
-    if __name__ == "__main__":
-        application = tornado.web.Application([
-            (r"/", MainHandler),
-            (r"/bmi/io", webio_handler(bmi)),  # bmi 即为上文中使用`PyWebIO`进行改造的函数
-            (r"/bmi/(.*)", tornado.web.StaticFileHandler, {"path": STATIC_PATH, 'default_filename': 'index.html'})
-        ])
-        application.listen(port=80, address='localhost')
-        tornado.ioloop.IOLoop.current().start()
-
-在 ``http://localhost/bmi/`` 页面上就可以计算BMI了
-
-Document
-------------
-
-使用手册和实现文档见 `https://pywebio.readthedocs.io <https://pywebio.readthedocs.io>`_

+ 1 - 1
docs/conf.py

@@ -13,7 +13,7 @@ import sys
 sys.path.insert(0, os.path.abspath(".."))
 import pywebio
 
-version = release = pywebio.version
+version = release = pywebio.__version__
 
 # -- Project information -----------------------------------------------------
 

+ 19 - 6
docs/guide.rst

@@ -131,7 +131,7 @@ PyWebIO提供了一些便捷函数来输出表格、链接等格式::
 
 PyWebIO提供的全部输出函数请见 :doc:`pywebio.output </output>` 模块
 
-输出事件回调
+事件回调
 ^^^^^^^^^^^^^^
 
 PyWebIO把程序与用户的交互分成了输入和输出两部分:输入函数为阻塞式调用,在用户提交表单之前将不会返回;对输出函数的调用将会立刻将内容输出至浏览器。
@@ -161,6 +161,9 @@ PyWebIO把程序与用户的交互分成了输入和输出两部分:输入函
         put_text("You click %s button" % btn_val)
     put_buttons(['A', 'B', 'C'], onclick=btn_click)
 
+.. note::
+   在PyWebIO会话(关于会话的概念见下文 :ref:`Server and script mode <server_and_script_mode>` )结束后,事件回调也将不起作用,你可以在任务函数末尾处使用 :func:`pywebio.session.hold()` 函数来将会话保持,这样在用户关闭浏览器前,事件回调将一直可用。
+
 锚点
 ^^^^^^^^^^^^^^
 就像在控制台输出文本一样,PyWebIO默认在页面的末尾输出各种内容,你可以使用锚点来改变这一行为。
@@ -211,6 +214,8 @@ PyWebIO支持两种外观:输出区固定高度/可变高度。
 在不指定锚点进行输出时,PyWebIO默认在输出完毕后自动将页面滚动到页面最下方;在调用输入函数时,也会将页面滚动到表单处。
 通过调用 `set_auto_scroll_bottom(False) <pywebio.output.set_auto_scroll_bottom>` 来关闭自动滚动。
 
+.. _server_and_script_mode:
+
 Server mode & Script mode
 ------------------------------------
 
@@ -246,8 +251,16 @@ PyWebIO 支持在多线程环境中使用。
 **Server mode**
 
 Server mode 下,由于对多会话的支持,如果需要在新创建的线程中使用PyWebIO的交互函数,需要手动调用 `register_thread(thread) <pywebio.session.register_thread>` 对新进程进行注册。
-如果新创建的线程中没有使用到PyWebIO的交互函数,则无需注册。
-当当前会话的任务函数和会话内通过 `register_thread(thread) <pywebio.session.register_thread>` 注册的线程都结束运行时,会话关闭。
+如果新创建的线程中没有使用到PyWebIO的交互函数,则无需注册。在没有使用 `register_thread(thread) <pywebio.session.register_thread>` 注册的线程不受会话管理,其调用PyWebIO的交互函数将会产生 `SessionNotFoundException <pywebio.exceptions.SessionNotFoundException>` 异常。
+当会话的任务函数和会话内通过 `register_thread(thread) <pywebio.session.register_thread>` 注册的线程都结束运行时,会话关闭。
+
+会话的结束
+^^^^^^^^^^^^^^
+
+会话还会因为用户的关闭浏览器而结束,这时当前会话内还未返回的PyWebIO输入函数调用将抛出 `SessionClosedException <pywebio.exceptions.SessionClosedException>` 异常,之后对于PyWebIO交互函数的调用将会产生 `SessionNotFoundException <pywebio.exceptions.SessionNotFoundException>` / `SessionClosedException <pywebio.exceptions.SessionClosedException>` 异常。
+
+可以使用 `defer_call(func) <pywebio.session.defer_call>` 来设置会话结束时需要调用的函数。无论是用户主动关闭会话还是任务结束会话关闭,设置的函数都会被执行。
+可以用于资源清理等工作。在会话中可以多次调用 `defer_call() <pywebio.session.defer_call>` ,会话结束后将会顺序执行设置的函数。
 
 
 与Web框架集成
@@ -400,6 +413,8 @@ PyWebIO的会话实现默认是基于线程的,用户每打开一个和服务
    在基于协程的会话中, :doc:`pywebio.input </input>` 模块中的输入函数都需要使用 ``await`` 语法来获取返回值,
    忘记使用 ``await`` 将会是在使用基于协程的会话时常出现的错误。
 
+   协程会话中,同样需要使用 ``await`` 语法来进行调用函数还有 :func:`pywebio.session.hold()`
+
 与Web框架进行集成
 ^^^^^^^^^^^^^^^^^^^^^
 
@@ -426,9 +441,7 @@ PyWebIO的会话实现默认是基于线程的,用户每打开一个和服务
         put_text('... World!')
 
     app = Flask(__name__)
-    app.route('/io', methods=['GET', 'POST', 'OPTIONS'])(
-        webio_view(hello_word)
-    )
+    app.add_url_rule('/io', 'webio_view', webio_view(hello_word), methods=['GET', 'POST', 'OPTIONS'])
 
     @app.route('/')
     @app.route('/<path:static_file>')

+ 3 - 3
docs/index.rst

@@ -17,11 +17,11 @@ PyWebIO还可以方便地整合进现有的Web服务,让你不需要编写Html
 Install
 ------------
 
-Pypi源安装::
+PyPi安装::
 
    pip3 install -U pywebio
 
-目前PyWebIO处于快速迭代时期,Pypi上的包更新可能滞后,建议使用源码安装::
+目前PyWebIO处于快速迭代时期,PyPi上的包更新可能滞后,建议使用源码安装::
 
     pip3 install -U https://code.aliyun.com/wang0618/pywebio/repository/archive.zip
 
@@ -32,7 +32,7 @@ Pypi源安装::
 Hello, world
 --------------
 
-这是一个使用PywWebIO计算 `BMI指数 <https://en.wikipedia.org/wiki/Body_mass_index>`_ 的脚本::
+这是一个使用PyWebIO计算 `BMI指数 <https://en.wikipedia.org/wiki/Body_mass_index>`_ 的脚本::
 
     # A simple script to calculate BMI
     from pywebio.input import input, FLOAT

+ 4 - 13
pywebio/__init__.py

@@ -1,22 +1,13 @@
-# version is a human-readable version number.
-
-# version_info is a four-tuple for programmatic comparison. The first
-# three numbers are the components of the version number.  The fourth
-# is zero for an official release, positive for a development branch,
-# or negative for a release candidate or beta (after the base version
-# number has been incremented)
-version = "0.1.0"
-version_info = (0, 1, 0, 0)
-
 from .platform import start_server
 from . import input
 from . import output
-from .session import (
-    run_async, run_asyncio_coroutine, register_thread,
-)
+from .session import *
 from .exceptions import SessionException, SessionClosedException, SessionNotFoundException
 from .utils import STATIC_PATH
 
+from .__version__ import __description__, __url__, __version__
+from .__version__ import __author__, __author_email__, __license__, __copyright__
+
 # Set default logging handler to avoid "No handler found" warnings.
 import logging
 from logging import NullHandler, StreamHandler

+ 9 - 0
pywebio/__version__.py

@@ -0,0 +1,9 @@
+__package__ = 'pywebio'
+__description__ = 'Write web app in script way.'
+__url__ = 'https://pywebio.readthedocs.io'
+__version__ = "0.1.0"
+__version_info__ = (0, 1, 0, 0)
+__author__ = 'WangWeimin'
+__author_email__ = 'wang0.618@qq.com'
+__license__ = 'MIT'
+__copyright__ = 'Copyright 2020 WangWeimin'

+ 0 - 0
pywebio/demos/__init__.py


+ 0 - 0
pywebio/demos/zh/__init__.py


+ 0 - 391
pywebio/demos/zh/overview.py

@@ -1,391 +0,0 @@
-"""
-使用PyWebIO来介绍PyWebIO的各个特性
-"""
-
-import asyncio
-from datetime import datetime
-from functools import partial
-
-from pywebio import start_server, run_async
-from pywebio.input import *
-from pywebio.output import *
-
-import argparse
-
-
-async def feature_overview():
-    set_auto_scroll_bottom(False)
-    set_output_fixed_height(False)
-    set_title("PyWebIO 特性一览")
-
-    put_markdown("""# PyWebIO 特性一览
-    你现在看到和即将看到的内容就是使用PyWebIO来创建的,"用自己来介绍自己" 是不是很有趣 😄(文末有彩蛋)
-
-    ## What is PyWebIO
-    PyWebIO,一个用于在浏览器上进行输入输出的工具库。能够将原有的通过终端交互的脚本快速服务化,供其他人在网络通过浏览器使用;PyWebIO还可以方便地整合进现有的web服务,非常适合于构建后端服务的功能原型。
-
-    特点:
-    - 使用同步而不是基于回调的方式获取输入,无需在各个步骤之间保存状态,直观、方便
-    - 代码侵入性小
-    - 支持并发请求
-    - 支持状态恢复
-    - 支持整合到现有的web服务,目前支持与Tronado的集成
-
-    对上面的内容一脸黑人问号,没关系,下面是一些PyWebIO是什么,以及能够做什么的直观的例子
-
-    ### 基本输入
-    首先是一些基本类型的输入
-
-    #### 文本输入
-    ```python
-    age = await input("How old are you?", type=NUMBER)  # type can be in {TEXT, NUMBER, PASSWORD}
-    ```
-    这样一行代码的效果如下,浏览器会弹出一个文本输入框来获取输入,在你提交表单之前,你的程序不会往下运行
-    """, lstrip=True)
-    age = await input("How old are you?", type=NUMBER)
-    put_text("你的年龄是:%s" % age)
-
-    put_markdown("""#### 下拉选择框
-    ```python
-    gift = await select('Which gift you want?', ['keyboard', 'ipad'])
-    ```
-    """, lstrip=True)
-    gift = await select('Which gift you want?', ['keyboard', 'ipad'])
-    put_text("%s sounds great!" % gift)
-
-    put_markdown("""#### CheckBox
-    ```python
-    agree = await checkbox("用户协议", options=['I agree to terms and conditions'])
-    ```
-    """, lstrip=True)
-    agree = await checkbox("用户协议", options=[{'value': 'agree', 'label': 'I agree to terms and conditions'}])
-    put_text("You %s to terms and conditions" % ('agree' if agree == 'agree' else 'disagree'))
-
-    put_markdown("""#### Text Area
-    ```python
-    text = await textarea('Text Area', rows='3', placeholder='Some text')
-    ```
-    """, lstrip=True)
-    text = await textarea('Text Area', rows='3', placeholder='Some text')
-    put_text('Your input:%s' % text)
-
-    put_markdown("""textarea还支持使用 <a href="https://codemirror.net/" target="_blank">Codemirror</a>实现代码风格的编辑区,只需使用`code`参数传入Codemirror支持的选项:
-    ```python
-    code = await textarea('Code', code={
-        'mode': "python",  # 代码语言
-        'theme': 'darcula',  # 使用darcula主题
-    }, value='import something\n# Write your python code')
-    ```
-    """, lstrip=True)
-    code = await textarea('Code', code={
-        'mode': "python",  # 代码语言
-        'theme': 'darcula',  # 使用darcula主题
-    }, value='import something\n# Write your python code')
-    put_markdown('Your code:\n```python\n%s\n```' % code)
-
-    put_markdown("""#### Actions
-    ```python
-    choice = await actions("What do you want in next?", ["Go homepage", "Quit"])
-    ```
-    """, lstrip=True)
-    choice = await actions("What do you want in next?", ["Go homepage", "Quit"])
-    put_text("You choose %s" % choice)
-
-    put_markdown("""#### 文件上传
-    ```python
-    img = await file_upload("Select a image:", accept="image/*")
-    ```
-    """, lstrip=True)
-    img = await file_upload("Select a image:", accept="image/*")
-    put_text("Image name: %s\nImage size: %d KB" % (img['filename'], len(img['content']) / 1000))
-
-    put_markdown("""### 输入选项
-    输入函数可指定的参数非常丰富,就比如:
-    ```python
-    await input('Help Text', type=TEXT, help_text='This is help text')
-    ```
-    """, lstrip=True)
-    await input('Help Text', type=TEXT, help_text='This is help text')
-
-    put_markdown("""```python
-    await input('Placeholder', type=TEXT, placeholder='This is placeholder')
-    ```
-    """, lstrip=True)
-    await input('Placeholder', type=TEXT, placeholder='This is placeholder')
-
-    put_markdown("""```python
-    await input('Readonly', type=TEXT, readonly=True, value="You can't change me")
-    ```
-    """, lstrip=True)
-    await input('Readonly', type=TEXT, readonly=True, value="You can't change me")
-
-    put_markdown("""我们可以为输入指定校验函数,校验函数校验通过时返回None,否则返回错误消息:
-    ```python
-    def check_age(p):  # 检验函数校验通过时返回None,否则返回错误消息
-        if p < 10:
-            return 'Too young!!'
-        if p > 60:
-            return 'Too old!!'
-
-    age = await input("How old are you?", type=NUMBER, valid_func=check_age)
-    ```
-    """, strip_indent=4)
-
-    def check_age(p):  # 检验函数校验通过时返回None,否则返回错误消息
-        if p < 18:
-            return 'Too young!!'
-        if p > 60:
-            return 'Too old!!'
-
-    age = await input("How old are you?", type=NUMBER, valid_func=check_age, help_text='你可以输入一些不合法的数字(比如10)来查看错误提示的效果')
-
-    put_markdown("""### 输入组
-    PyWebIO还支持一组输入, 返回结果为一个字典。input_group接受前面的单项输入组成的列表作为参数,同时为了在返回的结果中区别出每一项输入,还需要在单项输入函数中传入`name`参数,input_group返回的字典就是以单项输入函数中的`name`作为键。
-    ```python
-    data = await input_group("Basic info",[
-      input('Input your name', name='name'), 
-      input('Input your age', name='age', type=NUMBER, valid_func=check_age)
-    ], valid_func=check_form)
-    print(data['name'], data['age'])
-    ```
-    输入组中同样支持设置校验函数,其接受整个表单数据作为参数:
-    ```python
-    def check_form(data):  # 检验函数校验通过时返回None,否则返回 (input name,错误消息)
-        if len(data['name']) > 6:
-            return ('name', '名字太长!')
-        if data['age'] <= 0:
-            return ('age', '年龄不能为负数!')
-    ```
-    """, strip_indent=4)
-
-    def check_form(data):  # 检验函数校验通过时返回None,否则返回 (input name,错误消息)
-        """返回 (name, error_msg) 表示输入错误"""  # todo 也可返回单独error_msg表示错误消息
-        if len(data['name']) > 6:
-            return ('name', '名字太长!')
-        if data['age'] <= 0:
-            return ('age', '年龄不能为负数!')
-
-    data = await input_group("Basic info", [
-        input('Input your name', name='name'),
-        input('Input your age', name='age', type=NUMBER, valid_func=check_age)
-    ], valid_func=check_form)
-    put_text('Your name:%s\nYour age:%d' % (data['name'], data['age']))
-
-    put_markdown("""### 输出
-    PyWebIO也提供了一些便捷函数来输出表格,链接等格式
-    #### 基本输出
-    首先是文本输出:
-    ```python
-    # 文本输出
-    put_text("Hello world!")
-
-    # 表格输出
-    put_table([
-        ['商品', '价格'],
-        ['苹果', '5.5'],
-        ['香蕉', '7'],
-    ])
-
-    # Markdown输出
-    put_markdown('~~删除线~~')
-
-    # 文件输出
-    put_file('hello_word.txt', b'hello word!')
-    ```
-    """, strip_indent=4)
-    put_text("Hello world!")
-    put_table([
-        ['商品', '价格'],
-        ['苹果', '5.5'],
-        ['香蕉', '7'],
-    ])
-    put_markdown('~~删除线~~')
-    put_file('hello_word.txt', b'hello word!')
-
-    put_markdown("""#### 输出事件
-    通过刚刚的体验,相信聪明的你已经大概了解:PyWebIO可以通过调用不同的输入函数在浏览器中获取用户的输入,并且通过浏览器展示程序的输出。并且一旦调用 `await some_input_func()`,在表单提交之前程序将不会往下运行。
-    这种模式已经可以满足绝大部分的交互需求了,但是在某些场景下还是显得不太方便,就比如你通过表格输出了用户的登陆日志,用户可能希望对表格的某些行进行编辑或者对表格什么也不做,这个时候,你可能会使用一个`while`循环,并且在循环中调用`choice = await actions("What do you want in next?", ["Edit some rows", "Back"])`,如果用户选择了"Edit some rows",你还要接着询问用户希望编辑哪些行......,emm,想想就头大。
-    幸运的是,PyWebIO还支持输出可以绑定事件的按钮控件,非常适合上述场景的需求。
-    上述场景通过按钮控件实现如下:
-    ```python
-    from functools import partial
-    
-    def edit_row(choice, row):
-        put_text("You click %s button ar row %s" % (choice, row))
-
-    put_table([
-        ['Idx', 'Actions'],
-        [1, table_cell_buttons(['edit', 'delete'], onclick=partial(edit_row, row=1))],
-        [2, table_cell_buttons(['edit', 'delete'], onclick=partial(edit_row, row=2))],
-        [3, table_cell_buttons(['edit', 'delete'], onclick=partial(edit_row, row=3))],
-    ])
-    ```
-    """, strip_indent=4)
-
-    def edit_row(choice, row):
-        put_text("You click %s button ar row %s" % (choice, row))
-
-    put_table([
-        ['Idx', 'Actions'],
-        [1, table_cell_buttons(['edit', 'delete'], onclick=partial(edit_row, row=1))],
-        [2, table_cell_buttons(['edit', 'delete'], onclick=partial(edit_row, row=2))],
-        [3, table_cell_buttons(['edit', 'delete'], onclick=partial(edit_row, row=3))],
-    ])
-    put_markdown("""这样,你不必等待用户点击某个按钮,而是可以继续往下运行程序,当用户点击了某行中的按钮时,程序会自动调用相应的处理函数\n
-    当然,PyWebIO还支持单独的按钮控件:
-    ```python
-    def btn_click(btn_val):
-        put_text("You click btn_val button" % btn_val)
-    put_buttons(['A', 'B', 'C'], onclick=btn_click)
-    ```
-    """, strip_indent=4)
-
-    def btn_click(btn_val):
-        put_text("You click %s button" % btn_val)
-
-    put_buttons(['A', 'B', 'C'], onclick=btn_click)
-
-    await actions('', ['继续教程'])
-
-    put_markdown("""#### 锚点
-    你可以调用`set_anchor(name)`对当前输出位置进行标记,这一调用不会在用户浏览器上产生任何输出,需要与下面几个函数结合使用:
-    调用`set_anchor(name)`可以清除anchor锚点之前输出的内容
-    调用`clear_after(name)`可以清除anchor锚点之后输出的内容
-    调用`clear_range(start_anchor, end_ancher)`可以清除start_anchor到end_ancher锚点之间的内容
-    调用`scroll_to(name)`可以将页面滚动到anchor锚点处
-    """, strip_indent=4)
-
-    set_anchor('anchor')
-    put_markdown("""这个例子展示了锚点的一个用法:
-    ```python
-    import asyncio
-    from datetime import datetime
-
-    set_anchor('counter')
-    for i in range(15, -1, -1):
-        clear_after('counter')
-        put_text('倒计时:%s' % i)
-        await asyncio.sleep(1)  # 睡眠一秒钟
-    ```
-    """, strip_indent=4)
-    await actions('点击开始示例', ['开始示例'])
-    set_anchor('counter')
-    for i in range(5, -1, -1):
-        clear_after('counter')
-        put_text('倒计时:%s' % i)
-        await asyncio.sleep(1)  # 睡眠一秒钟
-
-    put_markdown("""#### 环境设置
-    ##### 输出区外观
-    PyWebIO支持两种外观:输出区固定高度/可变高度。
-    可以通过调用`set_output_fixed_height(True)`来开启输出区固定高度。\n
-    你现在看到的是输出区可变高度的形态,你可以点击下面的按钮来切换外观。
-    """, strip_indent=4)
-    put_buttons([
-        {'label': '输出区固定高度', 'value': 'fixed'},
-        {'label': '输出区可变高度', 'value': 'no-fix'}
-    ], lambda i: set_output_fixed_height(i == 'fixed'), small=True)
-
-    put_markdown("""不过你最好在程序一开始就设置好输出区外观,否则你可能就会像现在这样手足无措~
-
-    调用`set_title(title)`可以设置标题。\n
-    """, strip_indent=4)
-
-    async def set_title_btn(data):
-        title = await input("Input title")
-        set_title(title)
-
-    put_buttons(['设置标题'], onclick=set_title_btn)
-
-    await actions('', ['继续教程'])
-
-    put_markdown("""##### 自动滚动
-    通过调用`set_auto_scroll_bottom(True)`来开启自动滚动,当有新内容输出时会自动将页面滚动到底部。\n
-    """, strip_indent=4)
-    put_buttons([
-        {'label': '开启自动滚动', 'value': 'enable'},
-        {'label': '关闭自动滚动', 'value': 'disable'}
-    ], lambda i: set_auto_scroll_bottom(i == 'enable'), small=True)
-
-    put_markdown("""#### Async
-    由于PyWebIO是基于Tornado构建的,而Tornado又与Python标准库<a href="https://docs.python.org/3/library/asyncio.html" target="_blank">asyncio</a>兼容,所以在PyWebIO中,你也可以运行`asyncio`中的协程函数
-
-    这一点其实在上文已经出现过了,不记得了?
-    """, strip_indent=4)
-    put_buttons(['点此穿越🚀'], onclick=lambda _: scroll_to('anchor'))
-
-    #
-    put_markdown("""
-    上文中的例子,之所以要使用asyncio中的sleep函数而不是Python `time`标准库中的sleep函数,是因为Tornado以及`asyncio`实际上是一个单线程模型,当前协程当进行一些需要等待的操作时,可以使用`await`让出程序控制权,框架会选择协程授予执行控制权,而调用`time.sleep`并不会让出程序控制权,因此在程序等待的间隔内,其他协程无法得到执行。更具体的关于协程以及asyncio的讨论已经超出了PyWebIO的范畴,你可以取互联网搜索相关内容来进行了解。
-
-    回到PyWebIO,你也可以`await`自己编写的协程函数
-    ```python
-    import asyncio
-
-    async def request():
-        http_client = AsyncHTTPClient()
-        response = await http_client.fetch("http://example.com")
-        put_text(response.body)
-        return response
-
-    response = await request()
-    ```
-
-    `run_async`允许你在一个协程函数中在后台启动另一个协程函数,不会像使用`await`一样阻塞当前协程,当前协程可以继续往下执行。
-
-    ```python
-    import asyncio
-    from datetime import datetime
-
-    async def show_time():
-        text = await input("来自后台协程的输入请求", placeholder='随便输入点啥')
-        put_text('你刚刚输入了:%s' % text)
-        for _ in range(10):
-            put_text('来自后台协程的报时:%s' % datetime.now())
-            await asyncio.sleep(1)
-
-    run_async(show_time())
-    
-    for i in range(5, -1, -1):
-        put_text('来自主协程的倒计时:%s' % i)
-        await asyncio.sleep(1)
-    
-    ```
-
-    在新生成的协程内,依然可以调用输入函数,若用户当前已经有正在展示的输入表单,则会被新生成的表单替换,但是旧表单不会被销毁,旧表单的输入状态也会保留,当新表单提交后,旧输入表单会重新呈现给用户。
-    """, strip_indent=4)
-
-    async def show_time():
-        text = await input("来自后台协程的输入请求", placeholder='随便输入点啥')
-        put_text('你刚刚输入了:%s' % text)
-        for _ in range(10):
-            put_text('来自后台协程的报时:%s' % datetime.now())
-            await asyncio.sleep(1)
-
-    await actions('', ['运行run_async(show_time())'])
-
-    run_async(show_time())
-
-    for i in range(15, -1, -1):
-        put_text('来自主协程的倒计时:%s' % i)
-        await asyncio.sleep(1)
-
-    await asyncio.sleep(2)
-
-    put_markdown("""
-    <hr/>
-
-    以上大概就是 PyWebIO 的所有特性了,如果觉得还不错的话,可以 Give me a 🌟 in <a href="https://github.com/wang0618/PyWebIO" target="_blank">Github</a>
-
-    PS: <a href="https://github.com/wang0618/PyWebIO/blob/master/pywebio/demos/overview-zh.py" target="_blank">在这里</a>你可以找到生成本页面的脚本
-    PPS:开头提到的彩蛋揭晓:"用自己来介绍自己"很具计算机领域风格,对此发挥至极的是<a href="https://en.wikipedia.org/wiki/Quine_(computing)" target="_blank">Quine</a>的概念,"A quine is a program which prints a copy of its own as the only output. "
-    """, strip_indent=4)
-
-
-if __name__ == '__main__':
-    parser = argparse.ArgumentParser(description='PyWebIO Overview demo')
-    parser.add_argument('--host', default='localhost', help='server bind host')
-    parser.add_argument('--port', type=int, default=0, help='server bind port')
-    args = parser.parse_args()
-
-    start_server(feature_overview, debug=True, auto_open_webbrowser=True, host=args.host, port=args.port, allowed_origins=['http://localhost:63342'])

+ 1 - 0
pywebio/html/codemirror/base16-light.min.css

@@ -0,0 +1 @@
+.cm-s-base16-light.CodeMirror{background:#f5f5f5;color:#202020}.cm-s-base16-light div.CodeMirror-selected{background:#e0e0e0}.cm-s-base16-light .CodeMirror-line::selection,.cm-s-base16-light .CodeMirror-line>span::selection,.cm-s-base16-light .CodeMirror-line>span>span::selection{background:#e0e0e0}.cm-s-base16-light .CodeMirror-line::-moz-selection,.cm-s-base16-light .CodeMirror-line>span::-moz-selection,.cm-s-base16-light .CodeMirror-line>span>span::-moz-selection{background:#e0e0e0}.cm-s-base16-light .CodeMirror-gutters{background:#f5f5f5;border-right:0}.cm-s-base16-light .CodeMirror-guttermarker{color:#ac4142}.cm-s-base16-light .CodeMirror-guttermarker-subtle{color:#b0b0b0}.cm-s-base16-light .CodeMirror-linenumber{color:#b0b0b0}.cm-s-base16-light .CodeMirror-cursor{border-left:1px solid #505050}.cm-s-base16-light span.cm-comment{color:#8f5536}.cm-s-base16-light span.cm-atom{color:#aa759f}.cm-s-base16-light span.cm-number{color:#aa759f}.cm-s-base16-light span.cm-attribute,.cm-s-base16-light span.cm-property{color:#90a959}.cm-s-base16-light span.cm-keyword{color:#ac4142}.cm-s-base16-light span.cm-string{color:#f4bf75}.cm-s-base16-light span.cm-variable{color:#90a959}.cm-s-base16-light span.cm-variable-2{color:#6a9fb5}.cm-s-base16-light span.cm-def{color:#d28445}.cm-s-base16-light span.cm-bracket{color:#202020}.cm-s-base16-light span.cm-tag{color:#ac4142}.cm-s-base16-light span.cm-link{color:#aa759f}.cm-s-base16-light span.cm-error{background:#ac4142;color:#505050}.cm-s-base16-light .CodeMirror-activeline-background{background:#dddcdc}.cm-s-base16-light .CodeMirror-matchingbracket{color:#f5f5f5!important;background-color:#6a9fb5!important}

+ 0 - 53
pywebio/html/codemirror/darcula.css

@@ -1,53 +0,0 @@
-/**
-    Name: IntelliJ IDEA darcula theme
-    From IntelliJ IDEA by JetBrains
- */
-
-.cm-s-darcula  { font-family: Consolas, Menlo, Monaco, 'Lucida Console', 'Liberation Mono', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', 'Courier New', monospace, serif;}
-.cm-s-darcula.CodeMirror { background: #2B2B2B; color: #A9B7C6; }
-
-.cm-s-darcula span.cm-meta { color: #BBB529; }
-.cm-s-darcula span.cm-number { color: #6897BB; }
-.cm-s-darcula span.cm-keyword { color: #CC7832; line-height: 1em; font-weight: bold; }
-.cm-s-darcula span.cm-def { color: #A9B7C6; font-style: italic; }
-.cm-s-darcula span.cm-variable { color: #A9B7C6; }
-.cm-s-darcula span.cm-variable-2 { color: #A9B7C6; }
-.cm-s-darcula span.cm-variable-3 { color: #9876AA; }
-.cm-s-darcula span.cm-type { color: #AABBCC; font-weight: bold; }
-.cm-s-darcula span.cm-property { color: #FFC66D; }
-.cm-s-darcula span.cm-operator { color: #A9B7C6; }
-.cm-s-darcula span.cm-string { color: #6A8759; }
-.cm-s-darcula span.cm-string-2 { color: #6A8759; }
-.cm-s-darcula span.cm-comment { color: #61A151; font-style: italic; }
-.cm-s-darcula span.cm-link { color: #CC7832; }
-.cm-s-darcula span.cm-atom { color: #CC7832; }
-.cm-s-darcula span.cm-error { color: #BC3F3C; }
-.cm-s-darcula span.cm-tag { color: #629755; font-weight: bold; font-style: italic; text-decoration: underline; }
-.cm-s-darcula span.cm-attribute { color: #6897bb; }
-.cm-s-darcula span.cm-qualifier { color: #6A8759; }
-.cm-s-darcula span.cm-bracket { color: #A9B7C6; }
-.cm-s-darcula span.cm-builtin { color: #FF9E59; }
-.cm-s-darcula span.cm-special { color: #FF9E59; }
-.cm-s-darcula span.cm-matchhighlight { color: #FFFFFF; background-color: rgba(50, 89, 48, .7); font-weight: normal;}
-.cm-s-darcula span.cm-searching { color: #FFFFFF; background-color: rgba(61, 115, 59, .7); font-weight: normal;}
-
-.cm-s-darcula .CodeMirror-cursor { border-left: 1px solid #A9B7C6; }
-.cm-s-darcula .CodeMirror-activeline-background { background: #323232; }
-.cm-s-darcula .CodeMirror-gutters { background: #313335; border-right: 1px solid #313335; }
-.cm-s-darcula .CodeMirror-guttermarker { color: #FFEE80; }
-.cm-s-darcula .CodeMirror-guttermarker-subtle { color: #D0D0D0; }
-.cm-s-darcula .CodeMirrir-linenumber { color: #606366; }
-.cm-s-darcula .CodeMirror-matchingbracket { background-color: #3B514D; color: #FFEF28 !important; font-weight: bold; }
-
-.cm-s-darcula div.CodeMirror-selected { background: #214283; }
-
-.CodeMirror-hints.darcula {
-  font-family: Menlo, Monaco, Consolas, 'Courier New', monospace;
-  color: #9C9E9E;
-  background-color: #3B3E3F !important;
-}
-
-.CodeMirror-hints.darcula .CodeMirror-hint-active {
-  background-color: #494D4E !important;
-  color: #9C9E9E !important;
-}

+ 41 - 0
pywebio/html/css/app.css

@@ -91,4 +91,45 @@ h5.card-header:empty {
 
 button {
     margin-bottom: 8px;
+}
+
+.input-container .form-group {
+    margin-bottom: 0;
+}
+
+img {
+    -webkit-animation-name: image-load-in;
+    animation-name: image-load-in;
+    -webkit-animation-duration: .6s;
+    animation-duration: .6s
+}
+
+@-webkit-keyframes image-load-in {
+    0% {
+        -webkit-filter: blur(8px);
+        filter: blur(8px);
+        opacity: 0
+    }
+    100% {
+        -webkit-filter: blur(0);
+        filter: blur(0);
+        opacity: 1
+    }
+}
+
+@keyframes image-load-in {
+    0% {
+        -webkit-filter: blur(8px);
+        filter: blur(8px);
+        opacity: 0
+    }
+    100% {
+        -webkit-filter: blur(0);
+        filter: blur(0);
+        opacity: 1
+    }
+}
+
+.custom-file {
+    margin-bottom: 8px;
 }

+ 7 - 2
pywebio/html/index.html

@@ -8,7 +8,7 @@
     <link rel="stylesheet" href="css/mditor.min.css">
     <link rel="stylesheet" href="css/bootstrap.min.css">
     <link rel="stylesheet" href="css/codemirror.min.css">
-    <link rel="stylesheet" href="codemirror/darcula.css">
+    <link rel="stylesheet" href="codemirror/base16-light.min.css">
     <link rel="stylesheet" href="css/app.css">
 </head>
 <body>
@@ -53,9 +53,14 @@
 <script src="js/pywebio.js"></script>
 
 <script src="js/require.min.js"></script> <!-- JS module loader -->
-
 <script>
 
+    require.config({
+        paths: {
+            'plotly': "https://cdn.jsdelivr.net/npm/plotly.js@1.53.0/dist/plotly.min" // 'https://cdn.plot.ly/plotly-latest.min'
+        }
+    });
+
     /*
     * Check given `backend_addr` is a http backend
     * Usage:

+ 4 - 1
pywebio/html/js/pywebio.js

@@ -152,6 +152,7 @@
             }
 
             var elem = OutputController.prototype[func_name].call(this, msg.spec);
+            elem.hide();
             if (msg.spec.anchor !== undefined && this.container_elem.find(`#${msg.spec.anchor}`).length) {
                 var pos = this.container_elem.find(`#${msg.spec.anchor}`);
                 pos.empty().append(elem);
@@ -169,6 +170,7 @@
                     scroll_bottom = true;
                 }
             }
+            elem.fadeIn();
         } else if (msg.command === 'output_ctl') {
             this.handle_output_ctl(msg);
         }
@@ -672,6 +674,7 @@
         if (spec.code) {
             var that = this;
             var config = {
+                'theme': 'base16-light',
                 'mode': 'python',
                 'lineNumbers': true,  // 显示行数
                 'indentUnit': 4,  //缩进单位为4
@@ -683,7 +686,7 @@
                 config[k] = that.spec.code[k];
 
             CodeMirror.autoLoadMode(that.code_mirror, config.mode);
-            if (config.theme)
+            if (config.theme && config.theme !== 'base16-light')
                 load_codemirror_theme(config.theme);
 
             setTimeout(function () {  // 需要等待当前表单被添加到文档树中后,再初始化CodeMirror,否则CodeMirror样式会发生错误

+ 11 - 8
pywebio/input.py

@@ -45,12 +45,15 @@ __all__ = ['TEXT', 'NUMBER', 'FLOAT', 'PASSWORD', 'input', 'textarea', 'select',
            'checkbox', 'radio', 'actions', 'file_upload', 'input_group']
 
 
-def _parse_args(kwargs):
-    """处理传给各类输入函数的原始参数,
+def _parse_args(kwargs, excludes=()):
+    """处理传给各类输入函数的原始参数
+
+     - excludes: 排除的参数
+     - 对为None的参数忽略处理
+
     :return:(spec参数,valid_func)
     """
-    # 对为None的参数忽略处理
-    kwargs = {k: v for k, v in kwargs.items() if v is not None}
+    kwargs = {k: v for k, v in kwargs.items() if v is not None and k not in excludes}
     kwargs.update(kwargs.get('other_html_attrs', {}))
     kwargs.pop('other_html_attrs', None)
     valid_func = kwargs.pop('valid_func', lambda _: None)
@@ -177,6 +180,7 @@ def select(label='', options=None, *, multiple=None, valid_func=None, name=None,
     :param bool multiple: 是否可以多选. 默认单选
     :param value: 下拉选择框初始选中项的值。当 ``multiple=True`` 时, ``value`` 需为list,否则为单个选项的值。
        你也可以通过设置 ``options`` 列表项中的 ``selected`` 字段来设置默认选中选项。
+       最终选中项为 ``value`` 参数和 ``options`` 中设置的并集。
     :type value: list or str
     :param bool required: 是否至少选择一项
     :param - label, valid_func, name, help_text, other_html_attrs: 与 `input` 输入函数的同名参数含义一致
@@ -184,10 +188,9 @@ def select(label='', options=None, *, multiple=None, valid_func=None, name=None,
     """
     assert options is not None, ValueError('Required `options` parameter in select()')
 
-    item_spec, valid_func = _parse_args(locals())
+    item_spec, valid_func = _parse_args(locals(), excludes=['value'])
     item_spec['options'] = _parse_select_options(options)
     if value is not None:
-        del item_spec['value']
         item_spec['options'] = _set_options_selected(item_spec['options'], value)
     item_spec['type'] = SELECT
 
@@ -260,7 +263,7 @@ def _parse_action_buttons(buttons):
     for act in buttons:
         if isinstance(act, Mapping):
             assert 'label' in act, 'actions item must have label key'
-            assert 'value' in act or act.get('type', 'submit') != 'submit', \
+            assert 'value' in act or act.get('type', 'submit') != 'submit' or act.get('disabled'), \
                 'actions item must have value key for submit type'
         elif isinstance(act, (list, tuple)):
             assert len(act) in (2, 3, 4), 'actions item format error'
@@ -286,7 +289,7 @@ def actions(label='', buttons=None, name=None, help_text=None):
     :param list buttons: 选项列表。列表项的可用形式有:
 
         * dict: ``{label:选项标签, value:选项值, [type: 按钮类型], [disabled:是否禁止选择]}`` .
-          若 ``type='reset'/'cancel'`` 可省略 ``value``
+          若 ``type='reset'/'cancel'`` 或 ``disabled=True`` 可省略 ``value``
         * tuple or list: ``(label, value, [type], [disabled])``
         * 单值: 此时label和value使用相同的值
 

+ 2 - 22
pywebio/io_ctrl.py

@@ -4,10 +4,8 @@
 
 """
 import logging
-from functools import wraps
 
-from .session import get_session_implement, CoroutineBasedSession, get_current_task_id, get_current_session
-from .utils import run_as_function, to_coroutine
+from .session import chose_impl, next_client_event, get_current_task_id, get_current_session
 
 logger = logging.getLogger(__name__)
 
@@ -17,24 +15,6 @@ def send_msg(cmd, spec=None):
     get_current_session().send_task_command(msg)
 
 
-def chose_impl(gen_func):
-    @wraps(gen_func)
-    def inner(*args, **kwargs):
-        gen = gen_func(*args, **kwargs)
-        if get_session_implement() == CoroutineBasedSession:
-            return to_coroutine(gen)
-        else:
-            return run_as_function(gen)
-
-    return inner
-
-
-@chose_impl
-def next_event():
-    res = yield get_current_session().next_client_event()
-    return res
-
-
 @chose_impl
 def single_input(item_spec, valid_func, preprocess_func):
     """
@@ -106,7 +86,7 @@ def input_event_handle(item_valid_funcs, form_valid_funcs, preprocess_funcs):
     :return:
     """
     while True:
-        event = yield next_event()
+        event = yield next_client_event()
         event_name, event_data = event['event'], event['data']
         if event_name == 'input_event':
             input_event = event_data['event_name']

+ 10 - 5
pywebio/output.py

@@ -333,7 +333,7 @@ def table_cell_buttons(buttons, onclick, **callback_options):
         from functools import partial
 
         def edit_row(choice, row):
-            put_text("You click %s button ar row %s" % (choice, row))
+            put_text("You click %s button at row %s" % (choice, row))
 
         put_table([
             ['Idx', 'Actions'],
@@ -382,11 +382,13 @@ def put_buttons(buttons, onclick, small=False, anchor=None, before=None, after=N
                  after=after)
 
 
-def put_image(content, format=None, title='', anchor=None, before=None, after=None):
+def put_image(content, format=None, title='', width=None, height=None, anchor=None, before=None, after=None):
     """输出图片。
 
     :param content: 文件内容. 类型为 bytes-like object 或者为 ``PIL.Image.Image`` 实例
     :param str title: 图片描述
+    :param str width: 图像的宽度,单位是CSS像素(数字px)或者百分比(数字%)。
+    :param str height: 图像的高度,单位是CSS像素(数字px)或者百分比(数字%)。可以只指定 width 和 height 中的一个值,浏览器会根据原始图像进行缩放。
     :param str format: 图片格式。如 ``png`` , ``jpeg`` , ``gif`` 等
     :param str anchor, before, after: 与 `put_text` 函数的同名参数含义一致
     """
@@ -398,10 +400,13 @@ def put_image(content, format=None, title='', anchor=None, before=None, after=No
 
     format = '' if format is None else ('image/%s' % format)
 
+    width = 'width="%s"' % width if width is not None else ''
+    height = 'height="%s"' % height if height is not None else ''
+
     b64content = b64encode(content).decode('ascii')
-    html = r'<img src="data:{format};base64, {b64content}" alt="{title}" />'.format(format=format,
-                                                                                    b64content=b64content,
-                                                                                    title=title)
+    html = r'<img src="data:{format};base64, {b64content}" ' \
+           r'alt="{title}" {width} {height}/>'.format(format=format, b64content=b64content,
+                                                      title=title, height=height, width=width)
     put_html(html, anchor=anchor, before=before, after=after)
 
 

+ 78 - 33
pywebio/platform/flask.py

@@ -21,6 +21,7 @@ Flask backend
 """
 import asyncio
 import fnmatch
+import logging
 import threading
 import time
 from functools import partial
@@ -28,19 +29,20 @@ from typing import Dict
 
 from flask import Flask, request, jsonify, send_from_directory, Response
 
-from ..session import CoroutineBasedSession, get_session_implement, AbstractSession, \
-    register_session_implement_for_target
-from ..utils import STATIC_PATH
+from ..session import CoroutineBasedSession, AbstractSession, register_session_implement_for_target
+from ..utils import STATIC_PATH, iscoroutinefunction, isgeneratorfunction
 from ..utils import random_str, LRUDict
 
+logger = logging.getLogger(__name__)
+
 # todo: use lock to avoid thread race condition
 
 # type: Dict[str, AbstractSession]
 _webio_sessions = {}  # WebIOSessionID -> WebIOSession()
-_webio_expire = LRUDict()  # WebIOSessionID -> last active timestamp
+_webio_expire = LRUDict()  # WebIOSessionID -> last active timestamp。按照最后活跃时间递增排列
 
 DEFAULT_SESSION_EXPIRE_SECONDS = 60  # 超过60s会话不活跃则视为会话过期
-REMOVE_EXPIRED_SESSIONS_INTERVAL = 20  # 清理过期会话间隔(秒)
+SESSIONS_CLEANUP_INTERVAL = 20  # 清理过期会话间隔(秒)
 WAIT_MS_ON_POST = 100  # 在处理完POST请求时,等待WAIT_MS_ON_POST毫秒再读取返回数据。Task的command可以立即返回
 
 _event_loop = None
@@ -51,21 +53,31 @@ def _make_response(webio_session: AbstractSession):
 
 
 def _remove_expired_sessions(session_expire_seconds):
+    logger.debug("removing expired sessions")
+    """清除当前会话列表中的过期会话"""
     while _webio_expire:
         sid, active_ts = _webio_expire.popitem(last=False)
+
         if time.time() - active_ts < session_expire_seconds:
+            # 当前session未过期
             _webio_expire[sid] = active_ts
             _webio_expire.move_to_end(sid, last=False)
             break
-        del _webio_sessions[sid]
+
+        # 清理session
+        logger.debug("session %s expired" % sid)
+        session = _webio_sessions.get(sid)
+        if session:
+            session.close()
+            del _webio_sessions[sid]
 
 
 _last_check_session_expire_ts = 0  # 上次检查session有效期的时间戳
 
 
 def _remove_webio_session(sid):
-    del _webio_sessions[sid]
-    del _webio_expire[sid]
+    _webio_sessions.pop(sid, None)
+    _webio_expire.pop(sid, None)
 
 
 def cors_headers(origin, check_origin, headers=None):
@@ -82,10 +94,13 @@ def cors_headers(origin, check_origin, headers=None):
     return headers
 
 
-def _webio_view(target, session_cls, session_expire_seconds, check_origin):
+def _webio_view(target, session_cls, session_expire_seconds, session_cleanup_interval, check_origin):
     """
-    :param target:
-    :param session_expire_seconds:
+    :param target: 任务函数
+    :param session_cls: 会话实现类
+    :param session_expire_seconds: 会话不活跃过期时间。
+    :param session_cleanup_interval: 会话清理间隔。
+    :param callable check_origin: callback(origin) -> bool
     :return:
     """
     global _last_check_session_expire_ts, _event_loop
@@ -112,7 +127,6 @@ def _webio_view(target, session_cls, session_expire_seconds, check_origin):
         headers['webio-session-id'] = webio_session_id
         webio_session = session_cls(target)
         _webio_sessions[webio_session_id] = webio_session
-        _webio_expire[webio_session_id] = time.time()
     elif request.headers['webio-session-id'] not in _webio_sessions:  # WebIOSession deleted
         return jsonify([dict(command='close_session')])
     else:
@@ -120,15 +134,17 @@ def _webio_view(target, session_cls, session_expire_seconds, check_origin):
         webio_session = _webio_sessions[webio_session_id]
 
     if request.method == 'POST':  # client push event
-        webio_session.send_client_event(request.json)
-        time.sleep(WAIT_MS_ON_POST / 1000.0)
+        if request.json is not None:
+            webio_session.send_client_event(request.json)
+            time.sleep(WAIT_MS_ON_POST / 1000.0)
     elif request.method == 'GET':  # client pull messages
         pass
 
+    _webio_expire[webio_session_id] = time.time()
     # clean up at intervals
-    if time.time() - _last_check_session_expire_ts > REMOVE_EXPIRED_SESSIONS_INTERVAL:
-        _remove_expired_sessions(session_expire_seconds)
+    if time.time() - _last_check_session_expire_ts > session_cleanup_interval:
         _last_check_session_expire_ts = time.time()
+        _remove_expired_sessions(session_expire_seconds)
 
     response = _make_response(webio_session)
 
@@ -142,14 +158,24 @@ def _webio_view(target, session_cls, session_expire_seconds, check_origin):
     return response
 
 
-def webio_view(target, session_expire_seconds=DEFAULT_SESSION_EXPIRE_SECONDS, allowed_origins=None, check_origin=None):
+def webio_view(target,
+               session_expire_seconds=DEFAULT_SESSION_EXPIRE_SECONDS,
+               session_cleanup_interval=SESSIONS_CLEANUP_INTERVAL,
+               allowed_origins=None, check_origin=None):
     """获取用于与Flask进行整合的view函数
 
     :param target: 任务函数。任务函数为协程函数时,使用 :ref:`基于协程的会话实现 <coroutine_based_session>` ;任务函数为普通函数时,使用基于线程的会话实现。
+    :param int session_expire_seconds: 会话不活跃过期时间。
+    :param int session_cleanup_interval: 会话清理间隔。
     :param list allowed_origins: 除当前域名外,服务器还允许的请求的来源列表。
-    :param session_expire_seconds: 会话不活跃过期时间。
-    :param list allowed_origins: 除当前域名外,服务器还允许的请求的来源列表。
-        来源包含协议和域名和端口部分,允许使用 ``*`` 作为通配符。 比如 ``https://*.example.com`` 、 ``*://*.example.com`` 、
+        来源包含协议和域名和端口部分,允许使用 Unix shell 风格的匹配模式:
+
+        - ``*`` 为通配符
+        - ``?`` 匹配单个字符
+        - ``[seq]`` 匹配seq内的字符
+        - ``[!seq]`` 匹配不在seq内的字符
+
+        比如 ``https://*.example.com`` 、 ``*://*.example.com``
     :param callable check_origin: 请求来源检查函数。接收请求来源(包含协议和域名和端口部分)字符串,
         返回 ``True/False`` 。若设置了 ``check_origin`` , ``allowed_origins`` 参数将被忽略
     :return: Flask视图函数
@@ -160,11 +186,12 @@ def webio_view(target, session_expire_seconds=DEFAULT_SESSION_EXPIRE_SECONDS, al
     if check_origin is None:
         check_origin = lambda origin: any(
             fnmatch.fnmatch(origin, patten)
-            for patten in allowed_origins
+            for patten in allowed_origins or []
         )
 
     view_func = partial(_webio_view, target=target, session_cls=session_cls,
                         session_expire_seconds=session_expire_seconds,
+                        session_cleanup_interval=session_cleanup_interval,
                         check_origin=check_origin)
     view_func.__name__ = 'webio_view'
     return view_func
@@ -179,6 +206,7 @@ def run_event_loop(debug=False):
        See also: https://docs.python.org/3/library/asyncio-dev.html#asyncio-debug-mode
     """
     global _event_loop
+    CoroutineBasedSession.event_loop_thread_id = threading.current_thread().ident
     _event_loop = asyncio.new_event_loop()
     _event_loop.set_debug(debug)
     asyncio.set_event_loop(_event_loop)
@@ -188,6 +216,7 @@ def run_event_loop(debug=False):
 def start_server(target, port=8080, host='localhost',
                  allowed_origins=None, check_origin=None,
                  disable_asyncio=False,
+                 session_cleanup_interval=SESSIONS_CLEANUP_INTERVAL,
                  session_expire_seconds=DEFAULT_SESSION_EXPIRE_SECONDS,
                  debug=False, **flask_options):
     """启动一个 Flask server 来运行PyWebIO的 ``target`` 服务
@@ -197,31 +226,47 @@ def start_server(target, port=8080, host='localhost',
     :param port: server bind port. set ``0`` to find a free port number to use
     :param host: server bind host. ``host`` may be either an IP address or hostname.  If it's a hostname,
     :param list allowed_origins: 除当前域名外,服务器还允许的请求的来源列表。
-        来源包含协议和域名和端口部分,允许使用 ``*`` 作为通配符。 比如 ``https://*.example.com`` 、 ``*://*.example.com`` 、
+        来源包含协议和域名和端口部分,允许使用 Unix shell 风格的匹配模式:
+
+        - ``*`` 为通配符
+        - ``?`` 匹配单个字符
+        - ``[seq]`` 匹配seq内的字符
+        - ``[!seq]`` 匹配不在seq内的字符
+
+        比如 ``https://*.example.com`` 、 ``*://*.example.com``
     :param callable check_origin: 请求来源检查函数。接收请求来源(包含协议和域名和端口部分)字符串,
         返回 ``True/False`` 。若设置了 ``check_origin`` , ``allowed_origins`` 参数将被忽略
-    :param disable_asyncio: 禁用 asyncio 函数。仅在当 ``session_type=COROUTINE_BASED`` 时有效。
-        在Flask backend中使用asyncio需要单独开启一个线程来运行事件循环,
-        若程序中没有使用到asyncio中的异步函数,可以开启此选项来避免不必要的资源浪费
-    :param session_expire_seconds: 会话过期时间。若 session_expire_seconds 秒内没有收到客户端的请求,则认为会话过期。
-    :param debug: Flask debug mode
+    :param bool disable_asyncio: 禁用 asyncio 函数。仅在 ``target`` 为协程函数时有效。
+
+       .. note::  实现说明:
+           当使用Flask backend时,若要在PyWebIO的会话中使用 ``asyncio`` 标准库里的协程函数,则需要在单独开启一个线程来运行 ``asyncio`` 事件循环,
+           若程序中没有使用到 ``asyncio`` 中的异步函数,可以开启此选项来避免不必要的资源浪费
+
+    :param int session_expire_seconds: 会话过期时间。若 session_expire_seconds 秒内没有收到客户端的请求,则认为会话过期。
+    :param int session_cleanup_interval: 会话清理间隔。
+    :param bool debug: Flask debug mode
     :param flask_options: Additional keyword arguments passed to the constructor of ``flask.Flask.run``.
         ref: https://flask.palletsprojects.com/en/1.1.x/api/?highlight=flask%20run#flask.Flask.run
     """
 
     app = Flask(__name__)
-    app.route('/io', methods=['GET', 'POST', 'OPTIONS'])(
-        webio_view(target, session_expire_seconds,
-                   allowed_origins=allowed_origins,
-                   check_origin=check_origin)
-    )
+    app.add_url_rule('/io', 'webio_view', webio_view(
+        target,
+        session_expire_seconds=session_expire_seconds,
+        session_cleanup_interval=session_cleanup_interval,
+        allowed_origins=allowed_origins,
+        check_origin=check_origin
+    ), methods=['GET', 'POST', 'OPTIONS'])
 
     @app.route('/')
     @app.route('/<path:static_file>')
     def serve_static_file(static_file='index.html'):
         return send_from_directory(STATIC_PATH, static_file)
 
-    if not disable_asyncio and get_session_implement() is CoroutineBasedSession:
+    if not disable_asyncio and (iscoroutinefunction(target) or isgeneratorfunction(target)):
         threading.Thread(target=run_event_loop, daemon=True).start()
 
+    if not debug:
+        logging.getLogger('werkzeug').setLevel(logging.WARNING)
+
     app.run(host=host, port=port, debug=debug, **flask_options)

+ 41 - 12
pywebio/platform/tornado.py

@@ -6,6 +6,7 @@ import threading
 import webbrowser
 from functools import partial
 from urllib.parse import urlparse
+import os
 
 import tornado
 import tornado.httpserver
@@ -45,6 +46,7 @@ def _webio_handler(target, session_cls, check_origin_func=_is_same_site):
     """获取用于Tornado进行整合的RequestHandle类
 
     :param target: 任务函数
+    :param session_cls: 会话实现类
     :param callable check_origin_func: check_origin_func(origin, handler) -> bool
     :return: Tornado RequestHandle类
     """
@@ -80,7 +82,8 @@ def _webio_handler(target, session_cls, check_origin_func=_is_same_site):
 
         def on_message(self, message):
             data = json.loads(message)
-            self.session.send_client_event(data)
+            if data is not None:
+                self.session.send_client_event(data)
 
         def close_from_session(self):
             self._close_from_session_tag = True
@@ -99,7 +102,14 @@ def webio_handler(target, allowed_origins=None, check_origin=None):
 
     :param target: 任务函数。任务函数为协程函数时,使用 :ref:`基于协程的会话实现 <coroutine_based_session>` ;任务函数为普通函数时,使用基于线程的会话实现。
     :param list allowed_origins: 除当前域名外,服务器还允许的请求的来源列表。
-        来源包含协议和域名和端口部分,允许使用 ``*`` 作为通配符。 比如 ``https://*.example.com`` 、 ``*://*.example.com`` 、
+        来源包含协议和域名和端口部分,允许使用 Unix shell 风格的匹配模式:
+
+        - ``*`` 为通配符
+        - ``?`` 匹配单个字符
+        - ``[seq]`` 匹配seq内的字符
+        - ``[!seq]`` 匹配不在seq内的字符
+
+        比如 ``https://*.example.com`` 、 ``*://*.example.com`` 、
     :param callable check_origin: 请求来源检查函数。接收请求来源(包含协议和域名和端口部分)字符串,
         返回 ``True/False`` 。若设置了 ``check_origin`` , ``allowed_origins`` 参数将被忽略
     :return: Tornado RequestHandle类
@@ -111,7 +121,7 @@ def webio_handler(target, allowed_origins=None, check_origin=None):
         if allowed_origins:
             check_origin_func = partial(_check_origin, allowed_origins=allowed_origins)
     else:
-        check_origin_func = lambda origin, handler: check_origin(origin)
+        check_origin_func = lambda origin, handler: _is_same_site(origin, handler) or check_origin(origin)
 
     return _webio_handler(target=target, session_cls=session_cls, check_origin_func=check_origin_func)
 
@@ -157,10 +167,17 @@ def start_server(target, port=0, host='', debug=False,
         set empty string or to listen on all available interfaces.
     :param bool debug: Tornado debug mode
     :param list allowed_origins: 除当前域名外,服务器还允许的请求的来源列表。
-        来源包含协议和域名和端口部分,允许使用 ``*`` 作为通配符。 比如 ``https://*.example.com`` 、 ``*://*.example.com`` 、
+        来源包含协议和域名和端口部分,允许使用 Unix shell 风格的匹配模式:
+
+        - ``*`` 为通配符
+        - ``?`` 匹配单个字符
+        - ``[seq]`` 匹配seq内的字符
+        - ``[!seq]`` 匹配不在seq内的字符
+
+        比如 ``https://*.example.com`` 、 ``*://*.example.com``
     :param callable check_origin: 请求来源检查函数。接收请求来源(包含协议和域名和端口部分)字符串,
         返回 ``True/False`` 。若设置了 ``check_origin`` , ``allowed_origins`` 参数将被忽略
-    :param bool auto_open_webbrowser: Whether or not auto open web browser when server is started.
+    :param bool auto_open_webbrowser: Whether or not auto open web browser when server is started (if the operating system allows it) .
     :param int websocket_max_message_size: Max bytes of a message which Tornado can accept.
         Messages larger than the ``websocket_max_message_size`` (default 10MiB) will not be accepted.
     :param int websocket_ping_interval: If set to a number, all websockets will be pinged every n seconds.
@@ -187,7 +204,10 @@ def start_server(target, port=0, host='', debug=False,
 
 
 def start_server_in_current_thread_session():
-    """启动 script mode 的server"""
+    """启动 script mode 的server,监听可用端口,并自动打开浏览器
+
+    PYWEBIO_SCRIPT_MODE_PORT环境变量可以设置监听端口,并关闭自动打开浏览器,用于测试
+    """
     websocket_conn_opened = threading.Event()
     thread = threading.current_thread()
 
@@ -195,9 +215,9 @@ def start_server_in_current_thread_session():
         session = None
 
         def open(self):
-            self.main_sessin = False
+            self.main_session = False
             if SingleSessionWSHandler.session is None:
-                self.main_sessin = True
+                self.main_session = True
                 SingleSessionWSHandler.session = ScriptModeSession(thread,
                                                                    on_task_command=self.send_msg_to_client,
                                                                    loop=asyncio.get_event_loop())
@@ -206,19 +226,24 @@ def start_server_in_current_thread_session():
                 self.close()
 
         def on_close(self):
-            if SingleSessionWSHandler.session is not None and self.main_sessin:
+            if SingleSessionWSHandler.session is not None and self.main_session:
                 self.session.close()
                 logger.debug('ScriptModeSession closed')
 
     async def wait_to_stop_loop():
         """当只剩当前线程和Daemon线程运行时,关闭Server"""
-        alive_none_daemonic_thread_cnt = None
+        alive_none_daemonic_thread_cnt = None  # 包括当前线程在内的非Daemon线程数
         while alive_none_daemonic_thread_cnt != 1:
             alive_none_daemonic_thread_cnt = sum(
                 1 for t in threading.enumerate() if t.is_alive() and not t.isDaemon()
             )
             await asyncio.sleep(1)
 
+        # 关闭ScriptModeSession。
+        # 主动关闭ioloop时,SingleSessionWSHandler.on_close 并不会被调用,需要手动关闭session
+        if SingleSessionWSHandler.session:
+            SingleSessionWSHandler.session.close()
+
         # Current thread is only one none-daemonic-thread, so exit
         logger.debug('Closing tornado ioloop...')
         tornado.ioloop.IOLoop.current().stop()
@@ -227,9 +252,13 @@ def start_server_in_current_thread_session():
         loop = asyncio.new_event_loop()
         asyncio.set_event_loop(loop)
 
-        server, port = _setup_server(webio_handler=SingleSessionWSHandler, host='localhost')
+        port = 0
+        if os.environ.get("PYWEBIO_SCRIPT_MODE_PORT"):
+            port = int(os.environ.get("PYWEBIO_SCRIPT_MODE_PORT"))
+        server, port = _setup_server(webio_handler=SingleSessionWSHandler, port=port, host='localhost')
         tornado.ioloop.IOLoop.current().spawn_callback(wait_to_stop_loop)
-        tornado.ioloop.IOLoop.current().spawn_callback(open_webbrowser_on_server_started, 'localhost', port)
+        if "PYWEBIO_SCRIPT_MODE_PORT" not in os.environ:
+            tornado.ioloop.IOLoop.current().spawn_callback(open_webbrowser_on_server_started, 'localhost', port)
 
         tornado.ioloop.IOLoop.current().start()
         logger.debug('Tornado server exit')

+ 52 - 7
pywebio/session/__init__.py

@@ -2,12 +2,13 @@ r"""
 .. autofunction:: run_async
 .. autofunction:: run_asyncio_coroutine
 .. autofunction:: register_thread
+.. autofunction:: defer_call
+.. autofunction:: hold
+
 .. autoclass:: pywebio.session.coroutinebased.TaskHandle
    :members:
 """
 
-import asyncio
-import inspect
 import threading
 from functools import wraps
 
@@ -15,16 +16,17 @@ from .base import AbstractSession
 from .coroutinebased import CoroutineBasedSession
 from .threadbased import ThreadBasedSession, ScriptModeSession
 from ..exceptions import SessionNotFoundException
+from ..utils import iscoroutinefunction, isgeneratorfunction, run_as_function, to_coroutine
 
 # 当前进程中正在使用的会话实现的列表
 _active_session_cls = []
 
-__all__ = ['run_async', 'run_asyncio_coroutine', 'register_thread']
+__all__ = ['run_async', 'run_asyncio_coroutine', 'register_thread', 'hold', 'defer_call']
 
 
 def register_session_implement_for_target(target_func):
     """根据target_func函数类型注册会话实现,并返回会话实现"""
-    if asyncio.iscoroutinefunction(target_func) or inspect.isgeneratorfunction(target_func):
+    if iscoroutinefunction(target_func) or isgeneratorfunction(target_func):
         cls = CoroutineBasedSession
     else:
         cls = ThreadBasedSession
@@ -71,6 +73,8 @@ def get_current_task_id():
 
 def check_session_impl(session_type):
     def decorator(func):
+        """装饰器:在函数调用前检查当前会话实现是否满足要求"""
+
         @wraps(func)
         def inner(*args, **kwargs):
             curr_impl = get_session_implement()
@@ -91,9 +95,40 @@ def check_session_impl(session_type):
     return decorator
 
 
+def chose_impl(gen_func):
+    """根据当前会话实现来将 gen_func 转化为协程对象或直接以函数运行"""
+
+    @wraps(gen_func)
+    def inner(*args, **kwargs):
+        gen = gen_func(*args, **kwargs)
+        if get_session_implement() == CoroutineBasedSession:
+            return to_coroutine(gen)
+        else:
+            return run_as_function(gen)
+
+    return inner
+
+
+@chose_impl
+def next_client_event():
+    res = yield get_current_session().next_client_event()
+    return res
+
+
+@chose_impl
+def hold():
+    """保持会话,直到用户关闭浏览器,
+    此时函数抛出 `SessionClosedException <pywebio.exceptions.SessionClosedException>` 异常。
+
+    注意⚠️:在 :ref:`基于协程 <coroutine_based_session>` 的会话上下文中,需要使用 ``await hold()`` 语法来进行调用。
+    """
+    while True:
+        yield next_client_event()
+
+
 @check_session_impl(CoroutineBasedSession)
 def run_async(coro_obj):
-    """异步运行协程对象。协程中依然可以调用 PyWebIO 交互函数。 仅能在基于协程的会话上下文中调用
+    """异步运行协程对象。协程中依然可以调用 PyWebIO 交互函数。 仅能在 :ref:`基于协程 <coroutine_based_session>` 的会话上下文中调用
 
     :param coro_obj: 协程对象
     :return: An instance of  `TaskHandle <pywebio.session.coroutinebased.TaskHandle>` is returned, which can be used later to close the task.
@@ -103,7 +138,7 @@ def run_async(coro_obj):
 
 @check_session_impl(CoroutineBasedSession)
 async def run_asyncio_coroutine(coro_obj):
-    """若会话线程和运行事件的线程不是同一个线程,需要用 run_asyncio_coroutine 来运行asyncio中的协程。 仅能在基于协程的会话上下文中调用
+    """若会话线程和运行事件的线程不是同一个线程,需要用 run_asyncio_coroutine 来运行asyncio中的协程。 仅能在 :ref:`基于协程 <coroutine_based_session>` 的会话上下文中调用
 
     :param coro_obj: 协程对象
     """
@@ -112,8 +147,18 @@ async def run_asyncio_coroutine(coro_obj):
 
 @check_session_impl(ThreadBasedSession)
 def register_thread(thread: threading.Thread):
-    """注册线程,以便在线程内调用 PyWebIO 交互函数。仅能在基于线程的会话上下文中调用
+    """注册线程,以便在线程内调用 PyWebIO 交互函数。仅能在默认的基于线程的会话上下文中调用
 
     :param threading.Thread thread: 线程对象
     """
     return get_current_session().register_thread(thread)
+
+
+def defer_call(func):
+    """设置会话结束时调用的函数。无论是用户主动关闭会话还是任务结束会话关闭,设置的函数都会被运行。
+    可以用于资源清理等工作。
+    在会话中可以多次调用 `defer_call()` ,会话结束后将会顺序执行设置的函数。
+
+    :param func: 话结束时调用的函数
+    """
+    return get_current_session().defer_call(func)

+ 11 - 1
pywebio/session/base.py

@@ -11,6 +11,7 @@ class AbstractSession:
         on_task_exception
         register_callback
 
+        defer_call
 
     由Backend调用:
         send_client_event
@@ -43,7 +44,7 @@ class AbstractSession:
         """
         :param target:
         :param on_task_command: Backend向ession注册的处理函数,当 Session 收到task发送的command时调用
-        :param on_session_close: Backend向Session注册的处理函数,当 Session task执行结束时调用 *
+        :param on_session_close: Backend向Session注册的处理函数,当 Session task 执行结束时调用 *
         :param kwargs:
 
         .. note::
@@ -55,6 +56,7 @@ class AbstractSession:
         raise NotImplementedError
 
     def next_client_event(self) -> dict:
+        """获取来自客户端的下一个事件。阻塞调用,若在等待过程中,会话被用户关闭,则抛出SessionClosedException异常"""
         raise NotImplementedError
 
     def send_client_event(self, event):
@@ -79,3 +81,11 @@ class AbstractSession:
         ``callback`` 回调函数被执行, 并传入事件消息中的 ``data`` 字段值作为参数
         """
         raise NotImplementedError
+
+    def defer_call(self, func):
+        """设置会话结束时调用的函数。可以用于资源清理。
+        在会话中可以多次调用 `defer_call()` ,会话结束后将会顺序执行设置的函数。
+
+        :param func: 话结束时调用的函数
+        """
+        raise NotImplementedError

+ 64 - 21
pywebio/session/coroutinebased.py

@@ -1,14 +1,13 @@
 import asyncio
-import inspect
 import logging
 import sys
 import threading
 import traceback
 from contextlib import contextmanager
-
+from functools import partial
 from .base import AbstractSession
-from ..exceptions import SessionNotFoundException, SessionClosedException
-from ..utils import random_str
+from ..exceptions import SessionNotFoundException, SessionClosedException, SessionException
+from ..utils import random_str, isgeneratorfunction, iscoroutinefunction, catch_exp_call
 
 logger = logging.getLogger(__name__)
 
@@ -35,19 +34,29 @@ class CoroutineBasedSession(AbstractSession):
 
     当主协程任务和会话内所有通过 `run_async` 注册的协程都退出后,会话关闭。
     当用户浏览器主动关闭会话,CoroutineBasedSession.close 被调用, 协程任务和会话内所有通过 `run_async` 注册的协程都被关闭。
+
     """
 
+    # 运行事件循环的线程id
+    # 用于在 CoroutineBasedSession.get_current_session() 判断调用方是否合法
+    # Tornado backend时,在创建第一个CoroutineBasedSession时初始化
+    # Flask backend时,在platform.flaskrun_event_loop()时初始化
+    event_loop_thread_id = None
+
     _active_session_cnt = 0
 
     @classmethod
     def active_session_count(cls):
         return cls._active_session_cnt
 
-    @staticmethod
-    def get_current_session() -> "CoroutineBasedSession":
-        if _context.current_session is None or \
-                _context.current_session.session_thread_id != threading.current_thread().ident:
+    @classmethod
+    def get_current_session(cls) -> "CoroutineBasedSession":
+        if _context.current_session is None or cls.event_loop_thread_id != threading.current_thread().ident:
             raise SessionNotFoundException("No session found in current context!")
+
+        if _context.current_session.closed():
+            raise SessionClosedException
+
         return _context.current_session
 
     @staticmethod
@@ -62,7 +71,7 @@ class CoroutineBasedSession(AbstractSession):
         :param on_task_command: 由协程内发给session的消息的处理函数
         :param on_session_close: 会话结束的处理函数。后端Backend在相应on_session_close时关闭连接时,需要保证会话内的所有消息都传送到了客户端
         """
-        assert asyncio.iscoroutinefunction(target) or inspect.isgeneratorfunction(target), ValueError(
+        assert iscoroutinefunction(target) or isgeneratorfunction(target), ValueError(
             "CoroutineBasedSession accept coroutine function or generator function as task function")
 
         CoroutineBasedSession._active_session_cnt += 1
@@ -70,11 +79,16 @@ class CoroutineBasedSession(AbstractSession):
         self._on_task_command = on_task_command or (lambda _: None)
         self._on_session_close = on_session_close or (lambda: None)
 
+        # 会话结束时运行的函数
+        self.deferred_functions = []
+
         # 当前会话未被Backend处理的消息
         self.unhandled_task_msgs = []
 
-        # 创建会话的线程id。当前会话只能在本线程中使用
-        self.session_thread_id = threading.current_thread().ident
+        # 在创建第一个CoroutineBasedSession时 event_loop_thread_id 还未被初始化
+        # 则当前线程即为运行 event loop 的线程
+        if CoroutineBasedSession.event_loop_thread_id is None:
+            CoroutineBasedSession.event_loop_thread_id = threading.current_thread().ident
 
         # 会话内的协程任务
         self.coros = {}  # coro_task_id -> Task()
@@ -90,7 +104,7 @@ class CoroutineBasedSession(AbstractSession):
         self._step_task(main_task)
 
     def _step_task(self, task, result=None):
-        task.step(result)
+        asyncio.get_event_loop().call_soon_threadsafe(partial(task.step, result))
 
     def _on_task_finish(self, task: "Task"):
         self._alive_coro_cnt -= 1
@@ -115,7 +129,13 @@ class CoroutineBasedSession(AbstractSession):
         self._on_task_command(self)
 
     async def next_client_event(self):
+        # 函数开始不需要判断 self.closed()
+        # 如果会话关闭,对 get_current_session().next_client_event() 的调用会抛出SessionClosedException
+
         res = await WebIOFuture()
+        if res is None:
+            raise SessionClosedException
+
         return res
 
     def send_client_event(self, event):
@@ -128,7 +148,6 @@ class CoroutineBasedSession(AbstractSession):
         if not coro:
             logger.error('coro not found, coro_id:%s', coro_id)
             return
-
         self._step_task(coro, event)
 
     def get_task_commands(self):
@@ -138,6 +157,7 @@ class CoroutineBasedSession(AbstractSession):
 
     def _cleanup(self):
         for t in list(self.coros.values()):  # t.close() may cause self.coros changed size
+            t.step(None)  # 接收端接收到None消息会抛出SessionClosedException异常
             t.close()
         self.coros = {}  # delete session tasks
         CoroutineBasedSession._active_session_cnt -= 1
@@ -148,7 +168,11 @@ class CoroutineBasedSession(AbstractSession):
             return
         self._closed = True
         self._cleanup()
-        # todo clean
+
+        self.deferred_functions.reverse()
+        while self.deferred_functions:
+            func = self.deferred_functions.pop()
+            catch_exp_call(func, logger)
 
     def closed(self):
         return self._closed
@@ -178,18 +202,22 @@ class CoroutineBasedSession(AbstractSession):
 
         async def callback_coro():
             while True:
-                event = await self.next_client_event()
+                try:
+                    event = await self.next_client_event()
+                except SessionClosedException:
+                    return
+
                 assert event['event'] == 'callback'
                 coro = None
-                if asyncio.iscoroutinefunction(callback):
+                if iscoroutinefunction(callback):
                     coro = callback(event['data'])
-                elif inspect.isgeneratorfunction(callback):
+                elif isgeneratorfunction(callback):
                     coro = asyncio.coroutine(callback)(event['data'])
                 else:
                     try:
                         callback(event['data'])
                     except:
-                        CoroutineBasedSession.get_current_session().on_task_exception()
+                        self.on_task_exception()
 
                 if coro is not None:
                     if mutex_mode:
@@ -209,18 +237,26 @@ class CoroutineBasedSession(AbstractSession):
         :param coro_obj: 协程对象
         :return: An instance of  `TaskHandle` is returned, which can be used later to close the task.
         """
+        assert asyncio.iscoroutine(coro_obj), '`run_async()` only accept coroutine object'
+
         self._alive_coro_cnt += 1
 
         task = Task(coro_obj, session=self, on_coro_stop=self._on_task_finish)
         self.coros[task.coro_id] = task
-        asyncio.get_event_loop().call_soon(task.step)
+        asyncio.get_event_loop().call_soon_threadsafe(task.step)
         return task.task_handle()
 
     async def run_asyncio_coroutine(self, coro_obj):
         """若会话线程和运行事件的线程不是同一个线程,需要用 asyncio_coroutine 来运行asyncio中的协程"""
+        assert asyncio.iscoroutine(coro_obj), '`run_asyncio_coroutine()` only accept coroutine object'
+
         res = await WebIOFuture(coro=coro_obj)
         return res
 
+    def defer_call(self, func):
+        """设置会话结束时调用的函数。可以用于资源清理。"""
+        self.deferred_functions.append(func)
+
 
 class TaskHandle:
     """协程任务句柄"""
@@ -258,6 +294,7 @@ class Task:
 
     @staticmethod
     def gen_coro_id(coro=None):
+        """生成协程id"""
         name = 'coro'
         if hasattr(coro, '__name__'):
             name = coro.__name__
@@ -265,6 +302,11 @@ class Task:
         return '%s-%s' % (name, random_str(10))
 
     def __init__(self, coro, session: CoroutineBasedSession, on_coro_stop=None):
+        """
+        :param coro: 协程对象
+        :param session: 创建该Task的会话实例
+        :param on_coro_stop: 任务结束(正常结束或外部调用Task.close)时运行的回调
+        """
         self.session = session
         self.coro = coro
         self.coro_id = None
@@ -290,7 +332,8 @@ class Task:
                 logger.debug('Task[%s] finished', self.coro_id)
                 self.on_coro_stop(self)
             except Exception as e:
-                self.session.on_task_exception()
+                if not isinstance(e, SessionException):
+                    self.session.on_task_exception()
                 self.task_closed = True
                 self.on_coro_stop(self)
 
@@ -324,7 +367,7 @@ class Task:
 
     def __del__(self):
         if not self.task_closed:
-            logger.warning('Task[%s] not finished when destroy', self.coro_id)
+            logger.warning('Task[%s] was destroyed but it is pending!', self.coro_id)
 
     def task_handle(self):
         handle = TaskHandle(close=self.close, closed=lambda: self.task_closed)

+ 40 - 11
pywebio/session/threadbased.py

@@ -1,5 +1,3 @@
-import asyncio
-import inspect
 import logging
 import queue
 import sys
@@ -8,8 +6,8 @@ import traceback
 from functools import wraps
 
 from .base import AbstractSession
-from ..exceptions import SessionNotFoundException, SessionClosedException
-from ..utils import random_str, LimitedSizeQueue
+from ..exceptions import SessionNotFoundException, SessionClosedException, SessionException
+from ..utils import random_str, LimitedSizeQueue, isgeneratorfunction, iscoroutinefunction, catch_exp_call
 
 logger = logging.getLogger(__name__)
 
@@ -64,7 +62,7 @@ class ThreadBasedSession(AbstractSession):
         :param loop: 事件循环。若 on_task_command 或者 on_session_close 中有调用使用asyncio事件循环的调用,
             则需要事件循环实例来将回调在事件循环的线程中执行
         """
-        assert (not asyncio.iscoroutinefunction(target)) and (not inspect.isgeneratorfunction(target)), ValueError(
+        assert (not iscoroutinefunction(target)) and (not isgeneratorfunction(target)), ValueError(
             "ThreadBasedSession only accept a simple function as task function, "
             "not coroutine function or generator function. ")
 
@@ -74,6 +72,9 @@ class ThreadBasedSession(AbstractSession):
         self._on_session_close = on_session_close or (lambda: None)
         self._loop = loop
 
+        # 会话结束时运行的函数
+        self.deferred_functions = []
+
         self.threads = []  # 注册到当前会话的线程集合
         self.unhandled_task_msgs = LimitedSizeQueue(maxsize=self.unhandled_task_mq_maxsize)
 
@@ -94,12 +95,16 @@ class ThreadBasedSession(AbstractSession):
             try:
                 target()
             except Exception as e:
-                self.on_task_exception()
+                if not isinstance(e, SessionException):
+                    self.on_task_exception()
             finally:
                 for t in self.threads:
                     if t.is_alive() and t is not threading.current_thread():
                         t.join()
-                self.send_task_command(dict(command='close_session'))
+                try:
+                    self.send_task_command(dict(command='close_session'))
+                except SessionClosedException:
+                    pass
                 self._trigger_close_event()
                 self.close()
 
@@ -125,9 +130,15 @@ class ThreadBasedSession(AbstractSession):
             self._on_task_command(self)
 
     def next_client_event(self):
+        # 函数开始不需要判断 self.closed()
+        # 如果会话关闭,对 get_current_session().next_client_event() 的调用会抛出SessionNotFoundException
+
         task_id = self.get_current_task_id()
         event_mq = self.get_current_session().task_mqs.get(task_id)
-        return event_mq.get()
+        event = event_mq.get()
+        if event is None:
+            raise SessionClosedException
+        return event
 
     def send_client_event(self, event):
         """向会话发送来自用户浏览器的事件️
@@ -156,7 +167,6 @@ class ThreadBasedSession(AbstractSession):
             self._on_session_close()
 
     def _cleanup(self):
-        self.task_mqs = {}
 
         self.unhandled_task_msgs.wait_empty(8)
         if not self.unhandled_task_msgs.empty():
@@ -169,21 +179,33 @@ class ThreadBasedSession(AbstractSession):
         if self.callback_mq is not None:  # 回调功能已经激活
             self.callback_mq.put(None)  # 结束回调线程
 
+        for mq in self.task_mqs.values():
+            mq.put(None)  # 消费端接收到None消息会抛出SessionClosedException异常
+
+        self.task_mqs = {}
+
         ThreadBasedSession._active_session_cnt -= 1
 
     def close(self):
         """关闭当前Session。由Backend调用"""
+        # todo self._closed 会有竞争条件
         if self._closed:
             return
         self._closed = True
+
         self._cleanup()
 
+        self.deferred_functions.reverse()
+        while self.deferred_functions:
+            func = self.deferred_functions.pop()
+            catch_exp_call(func, logger)
+
     def closed(self):
         return self._closed
 
     def on_task_exception(self):
         from ..output import put_markdown  # todo
-        logger.exception('Error in coroutine executing')
+        logger.exception('Error in thread executing')
         type, value, tb = sys.exc_info()
         tb_len = len(list(traceback.walk_tb(tb)))
         lines = traceback.format_exception(type, value, tb, limit=1 - tb_len)
@@ -248,7 +270,7 @@ class ThreadBasedSession(AbstractSession):
 
         :param bool serial_mode: 串行模式模式。若为 ``True`` ,则对于同一组件的点击事件,串行执行其回调函数
         """
-        assert (not asyncio.iscoroutinefunction(callback)) and (not inspect.isgeneratorfunction(callback)), ValueError(
+        assert (not iscoroutinefunction(callback)) and (not isgeneratorfunction(callback)), ValueError(
             "In ThreadBasedSession.register_callback, `callback` must be a simple function, "
             "not coroutine function or generator function. ")
 
@@ -268,6 +290,10 @@ class ThreadBasedSession(AbstractSession):
         event_mq = queue.Queue(maxsize=self.event_mq_maxsize)  # 线程内的用户事件队列
         self.task_mqs[self._get_task_id(t)] = event_mq
 
+    def defer_call(self, func):
+        """设置会话结束时调用的函数。可以用于资源清理。"""
+        self.deferred_functions.append(func)
+
 
 class ScriptModeSession(ThreadBasedSession):
     """Script mode的会话实现"""
@@ -307,6 +333,9 @@ class ScriptModeSession(ThreadBasedSession):
         self._on_session_close = lambda: None
         self._loop = loop
 
+        # 会话结束时运行的函数
+        self.deferred_functions = []
+
         self.threads = []  # 当前会话的线程
         self.unhandled_task_msgs = LimitedSizeQueue(maxsize=self.unhandled_task_mq_maxsize)
 

+ 28 - 2
pywebio/utils.py

@@ -1,12 +1,13 @@
 import asyncio
+import functools
+import inspect
+import queue
 import random
 import socket
 import string
 import time
 from collections import OrderedDict
 from contextlib import closing
-import queue
-
 from os.path import abspath, dirname
 
 project_dir = dirname(abspath(__file__))
@@ -14,6 +15,31 @@ project_dir = dirname(abspath(__file__))
 STATIC_PATH = '%s/html' % project_dir
 
 
+def catch_exp_call(func, logger):
+    """运行函数,将捕获异常记录到日志
+
+    :param func: 函数
+    :param logger: 日志
+    :return: ``func`` 返回值
+    """
+    try:
+        return func()
+    except:
+        logger.exception("Error when invoke `%s`" % func)
+
+
+def iscoroutinefunction(object):
+    while isinstance(object, functools.partial):
+        object = object.func
+    return asyncio.iscoroutinefunction(object)
+
+
+def isgeneratorfunction(object):
+    while isinstance(object, functools.partial):
+        object = object.func
+    return inspect.isgeneratorfunction(object)
+
+
 class LimitedSizeQueue(queue.Queue):
     """
     有限大小的队列

+ 31 - 9
setup.py

@@ -1,22 +1,34 @@
+import os
+
 from setuptools import setup, find_packages
 
-from pywebio import version
+here = os.path.abspath(os.path.dirname(__file__))
+
+about = {}
+with open(os.path.join(here, 'pywebio', '__version__.py')) as f:
+    exec(f.read(), about)
+
+with open('README.md') as f:
+    readme = f.read()
 
 setup(
-    name='PyWebIO',
-    version=version,
-    description=u'Make your python interactive script be a web service.',
-    url='https://github.com/wang0618/pywebio',
-    author='WangWeimin',
-    author_email='wang0.618@qq.com',
-    license='MIT',
+    name=about['__package__'],
+    version=about['__version__'],
+    description=about['__description__'],
+    long_description=readme,
+    long_description_content_type='text/markdown',
+    author=about['__author__'],
+    author_email=about['__author_email__'],
+    url=about['__url__'],
+    license=about['__license__'],
+    python_requires=">=3.5.2",
     packages=find_packages(),
     package_data={
         # data files need to be listed both here (which determines what gets
         # installed) and in MANIFEST.in (which determines what gets included
         # in the sdist tarball)
         "pywebio": [
-            "html/codemirror/darcula.css",
+            "html/codemirror/base16-light.min.css",
             "html/codemirror/active-line.js",
             "html/codemirror/matchbrackets.js",
             "html/codemirror/loadmode.js",
@@ -51,11 +63,21 @@ setup(
         "Programming Language :: Python :: 3.5",
         "Programming Language :: Python :: 3.6",
         "Programming Language :: Python :: 3.7",
+        "Programming Language :: Python :: 3.8",
     ],
     install_requires=[
         'tornado>=4.3.0',  # After this version, the new async/await keywords in Python 3.5 are supported
     ],
     extras_require={
         'flask': ['flask'],
+        'dev': [
+            'selenium==3.*',
+            'percy-python-selenium',
+            'coverage',
+        ]
+    },
+    project_urls={
+        'Documentation': 'https://pywebio.readthedocs.io',
+        'Source': 'https://github.com/wang0618/PyWebIO',
     },
 )

+ 33 - 0
test/1.basic_output.py

@@ -0,0 +1,33 @@
+import subprocess
+
+from selenium.webdriver import Chrome
+
+import pywebio
+import template
+import util
+from pywebio import start_server
+from pywebio.output import *
+from pywebio.session import *
+
+
+def target():
+    set_auto_scroll_bottom(False)
+
+    template.basic_output()
+
+    template.background_output()
+
+    hold()
+
+
+def test(server_proc: subprocess.Popen, browser: Chrome):
+    template.test_output(browser)
+
+
+def start_test_server():
+    pywebio.enable_debug()
+    start_server(target, port=8080, debug=True, auto_open_webbrowser=False)
+
+
+if __name__ == '__main__':
+    util.run_test(start_test_server, test)

+ 37 - 0
test/2.basic_input.py

@@ -0,0 +1,37 @@
+import subprocess
+
+from selenium.webdriver import Chrome
+
+import pywebio
+import template
+import util
+from pywebio import start_server
+from pywebio.input import actions
+from pywebio.output import *
+from pywebio.utils import run_as_function
+
+
+def target():
+    set_auto_scroll_bottom(True)
+
+    template.set_defer_call()
+
+    run_as_function(template.basic_input())
+
+    actions(buttons=['Continue'])
+
+    template.background_input()
+
+
+def test(server_proc: subprocess.Popen, browser: Chrome):
+    template.test_input(browser)
+    template.test_defer_call()
+
+
+def start_test_server():
+    pywebio.enable_debug()
+    start_server(target, port=8080, debug=False, auto_open_webbrowser=False)
+
+
+if __name__ == '__main__':
+    util.run_test(start_test_server, test)

+ 47 - 0
test/3.script_mode.py

@@ -0,0 +1,47 @@
+import os
+import subprocess
+
+import time
+from percy import percySnapshot
+from selenium.webdriver import Chrome
+
+import template
+import util
+from pywebio.input import *
+from pywebio.output import *
+from pywebio.utils import run_as_function
+
+
+def target():
+    set_auto_scroll_bottom(True)
+
+    template.basic_output()
+
+    template.background_output()
+
+    run_as_function(template.basic_input())
+
+    actions(buttons=['Continue'])
+
+    template.background_input()
+
+
+def test(server_proc: subprocess.Popen, browser: Chrome):
+    percy_prefix = '[script_mode]'
+
+    template.test_output(browser, percy_prefix=percy_prefix)
+
+    time.sleep(1)
+
+    template.test_input(browser, percy_prefix=percy_prefix)
+
+    # script mode 下,此时 server 应停止
+    server_proc.wait(timeout=8)
+    percySnapshot(browser=browser, name=percy_prefix + 'over')
+
+
+if __name__ == '__main__':
+    # 设置监听端口,并关闭自动打开浏览器
+    os.environ["PYWEBIO_SCRIPT_MODE_PORT"] = "8080"
+
+    util.run_test(target, test)

+ 44 - 0
test/4.flask_backend.py

@@ -0,0 +1,44 @@
+import subprocess
+
+import time
+from percy import percySnapshot
+from selenium.webdriver import Chrome
+
+import pywebio
+import template
+import util
+from pywebio.input import *
+from pywebio.output import *
+from pywebio.utils import run_as_function
+from pywebio.platform.flask import start_server
+
+
+def target():
+    set_auto_scroll_bottom(False)
+
+    template.basic_output()
+
+    template.background_output()
+
+    run_as_function(template.basic_input())
+
+    actions(buttons=['Continue'])
+
+    template.background_input()
+
+
+def test(server_proc: subprocess.Popen, browser: Chrome):
+    template.test_output(browser, percy_prefix='[flask]')
+
+    time.sleep(1)
+
+    template.test_input(browser, percy_prefix='[flask]')
+
+
+def start_test_server():
+    pywebio.enable_debug()
+    start_server(target, port=8080)
+
+
+if __name__ == '__main__':
+    util.run_test(start_test_server, test)

+ 44 - 0
test/5.coroutine_based_session.py

@@ -0,0 +1,44 @@
+import subprocess
+
+import time
+from percy import percySnapshot
+from selenium.webdriver import Chrome
+
+import pywebio
+import template
+import util
+from pywebio.input import *
+from pywebio.output import *
+from pywebio.utils import to_coroutine
+from pywebio import start_server
+
+
+async def target():
+    set_auto_scroll_bottom(True)
+
+    template.basic_output()
+
+    await template.coro_background_output()
+
+    await to_coroutine(template.basic_input())
+
+    await actions(buttons=['Continue'])
+
+    await template.coro_background_input()
+
+
+def test(server_proc: subprocess.Popen, browser: Chrome):
+    template.test_output(browser, percy_prefix='[coro]')
+
+    time.sleep(1)
+
+    template.test_input(browser, percy_prefix='[coro]')
+
+
+def start_test_server():
+    pywebio.enable_debug()
+    start_server(target, port=8080, debug=True)
+
+
+if __name__ == '__main__':
+    util.run_test(start_test_server, test)

+ 43 - 0
test/6.flask_coroutine.py

@@ -0,0 +1,43 @@
+import subprocess
+
+import time
+from selenium.webdriver import Chrome
+
+import pywebio
+import template
+import util
+from pywebio.input import *
+from pywebio.output import *
+from pywebio.platform.flask import start_server
+from pywebio.utils import to_coroutine
+
+
+async def target():
+    set_auto_scroll_bottom(True)
+
+    template.basic_output()
+
+    await template.coro_background_output()
+
+    await to_coroutine(template.basic_input())
+
+    await actions(buttons=['Continue'])
+
+    await template.flask_coro_background_input()
+
+
+def test(server_proc: subprocess.Popen, browser: Chrome):
+    template.test_output(browser, percy_prefix='[flask coro]')
+
+    time.sleep(1)
+
+    template.test_input(browser, percy_prefix='[flask coro]')
+
+
+def start_test_server():
+    pywebio.enable_debug()
+    start_server(target, port=8080)
+
+
+if __name__ == '__main__':
+    util.run_test(start_test_server, test)

+ 78 - 0
test/7.multiple_session_impliment.py

@@ -0,0 +1,78 @@
+import subprocess
+
+from selenium.webdriver import Chrome
+
+import pywebio
+import template
+import util, time
+from pywebio.input import *
+from pywebio.output import *
+from pywebio.utils import to_coroutine, run_as_function
+
+
+def target():
+    set_auto_scroll_bottom(True)
+
+    template.basic_output()
+
+    template.background_output()
+
+    run_as_function(template.basic_input())
+
+    actions(buttons=['Continue'])
+
+    template.background_input()
+
+
+async def async_target():
+    set_auto_scroll_bottom(True)
+
+    template.basic_output()
+
+    await template.coro_background_output()
+
+    await to_coroutine(template.basic_input())
+
+    await actions(buttons=['Continue'])
+
+    await template.coro_background_input()
+
+
+def test(server_proc: subprocess.Popen, browser: Chrome):
+    template.test_output(browser, percy_prefix='[multi tornado coro]')
+
+    time.sleep(1)
+
+    template.test_input(browser, percy_prefix='[multi tornado coro]')
+
+    time.sleep(3)
+
+    browser.get('http://localhost:8080?_pywebio_debug=1&pywebio_api=io2')
+
+    template.test_output(browser, percy_prefix='[multi tornado thread]')
+
+    time.sleep(1)
+
+    template.test_input(browser, percy_prefix='[multi tornado thread]')
+
+
+def start_test_server():
+    pywebio.enable_debug()
+
+    import tornado.ioloop
+    import tornado.web
+    from pywebio.platform.tornado import webio_handler
+    from pywebio import STATIC_PATH
+
+    application = tornado.web.Application([
+        (r"/io", webio_handler(async_target)),
+        (r"/io2", webio_handler(target)),
+        (r"/(.*)", tornado.web.StaticFileHandler,
+         {"path": STATIC_PATH, 'default_filename': 'index.html'})
+    ])
+    application.listen(port=8080, address='localhost')
+    tornado.ioloop.IOLoop.current().start()
+
+
+if __name__ == '__main__':
+    util.run_test(start_test_server, test)

+ 86 - 0
test/8.flask_multiple_session_impliment.py

@@ -0,0 +1,86 @@
+import subprocess
+
+from selenium.webdriver import Chrome
+
+import pywebio
+import template
+import time
+import util
+from pywebio.input import *
+from pywebio.output import *
+from pywebio.utils import to_coroutine, run_as_function
+
+
+def target():
+    set_auto_scroll_bottom(False)
+
+    template.basic_output()
+
+    template.background_output()
+
+    run_as_function(template.basic_input())
+
+    actions(buttons=['Continue'])
+
+    template.background_input()
+
+
+async def async_target():
+    set_auto_scroll_bottom(False)
+
+    template.basic_output()
+
+    await template.coro_background_output()
+
+    await to_coroutine(template.basic_input())
+
+    await actions(buttons=['Continue'])
+
+    await template.coro_background_input()
+
+
+def test(server_proc: subprocess.Popen, browser: Chrome):
+
+    template.test_output(browser, percy_prefix='[multi flask coro]')
+
+    time.sleep(1)
+
+    template.test_input(browser, percy_prefix='[multi flask coro]')
+
+    time.sleep(3)
+
+    browser.get('http://localhost:8080?_pywebio_debug=1&pywebio_api=io2')
+
+    template.test_output(browser, percy_prefix='[multi flask thread]')
+
+    time.sleep(1)
+
+    template.test_input(browser, percy_prefix='[multi flask thread]')
+
+
+def start_test_server():
+    pywebio.enable_debug()
+    from flask import Flask, send_from_directory
+    from pywebio.platform.flask import webio_view, run_event_loop
+    from pywebio import STATIC_PATH
+    import threading
+    import logging
+
+    app = Flask(__name__)
+    app.add_url_rule('/io', 'webio_view', webio_view(target), methods=['GET', 'POST', 'OPTIONS'])
+    app.add_url_rule('/io2', 'webio_view_async_target', webio_view(async_target), methods=['GET', 'POST', 'OPTIONS'])
+
+    @app.route('/')
+    @app.route('/<path:static_file>')
+    def serve_static_file(static_file='index.html'):
+        return send_from_directory(STATIC_PATH, static_file)
+
+    threading.Thread(target=run_event_loop, daemon=True).start()
+
+    logging.getLogger('werkzeug').setLevel(logging.WARNING)
+
+    app.run(port=8080)
+
+
+if __name__ == '__main__':
+    util.run_test(start_test_server, test)

+ 20 - 0
test/Readme.md

@@ -0,0 +1,20 @@
+## Test
+使用 selenium 进行 + percy 进行测试。
+
+测试的原理为使用selenium打开编写的PyWebIO测试服务,在页面上进行模拟操作,
+将一些时刻的网页快照使用percy进行保存,percy可以比较不同提交之间的相同页面的区别。
+
+### 编写测试用例
+// todo
+
+### 运行测试用例
+
+```bash
+pip3 install -e ".[dev]" 
+npm install -D @percy/agent
+export PERCY_TOKEN=[projects-token]
+
+npx percy exec -- python3 1.basic_output.py auto
+```
+
+

二進制
test/assets/img.png


+ 24 - 0
test/run_all.py

@@ -0,0 +1,24 @@
+import os
+import subprocess
+from os import path
+
+here_dir = path.dirname(path.abspath(__file__))
+
+
+def run_all_test():
+    """顺序运行所有测试用例"""
+    files = [f for f in os.listdir(here_dir) if path.isfile(f) and f.split('.', 1)[0].isdigit()]
+    files.sort(key=lambda f: int(f.split('.', 1)[0]))
+
+    for f in files:
+        file = path.join(here_dir, f)
+        print("Run test script: %s" % file)
+        res = subprocess.run(['python3', file, 'auto'], text=True, shell=True)
+        if res.stdout:
+            print(res.stdout)
+        if res.stderr:
+            print(res.stderr)
+
+
+if __name__ == '__main__':
+    run_all_test()

+ 11 - 0
test/run_all.sh

@@ -0,0 +1,11 @@
+#!/bin/bash
+
+cd test
+
+python3 1.basic_output.py auto
+python3 2.basic_input.py auto
+python3 3.script_mode.py auto
+python3 4.flask_backend.py auto
+python3 5.coroutine_based_session.py auto
+python3 6.flask_coroutine.py auto
+python3 7.multiple_session_impliment.py auto

+ 488 - 0
test/template.py

@@ -0,0 +1,488 @@
+import asyncio
+import json
+import os
+import threading
+from functools import partial
+from os import path
+
+import time
+from percy import percySnapshot
+from selenium.webdriver import Chrome
+from selenium.webdriver.support.ui import Select
+
+from pywebio.input import *
+from pywebio.output import *
+from pywebio.session import *
+
+here_dir = path.dirname(path.abspath(__file__))
+
+
+def get_visible_form(browser):
+    forms = browser.find_elements_by_css_selector('#input-container > div')
+    for f in forms:
+        if f.is_displayed():
+            return f
+
+
+def basic_output():
+    set_anchor('top')
+
+    for i in range(3):
+        put_text('text_%s' % i)
+
+    put_text('测试空格:20空格:[%s]结束' % (' ' * 20))
+
+    for i in range(3):
+        put_text('inline_text_%s' % i, inline=True)
+
+    put_markdown("""### put_markdown 测试
+    `行内代码`
+
+    无序列表:
+    - 北京
+    - 上海
+    - 天津
+
+    有序列表:
+    1. 北京
+    2. 上海
+    3. 天津
+
+    [链接](./#)
+    ~~删除线~~
+    """, lstrip=True, anchor='put_markdown')
+
+    put_text('<hr/>:')
+    put_html("<hr/>", anchor='put_html')
+
+    put_text('code:')
+    put_code(json.dumps(dict(name='pywebio', author='wangweimin'), indent=4), 'json', anchor='put_code')
+
+    put_text('table:')
+    put_table([
+        ['Name', 'Gender', 'Address'],
+        ['Wang', 'M', 'China'],
+        ['Liu', 'W', 'America'],
+    ])
+
+    put_table([
+        ['Wang', 'M', 'China'],
+        ['Liu', 'W', 'America'],
+    ], header=['Name', 'Gender', 'Address'])
+
+    put_table([
+        {"Course": "OS", "Score": "80"},
+        {"Course": "DB", "Score": "93"},
+    ], header=["Course", "Score"], anchor='put_table')
+
+    def edit_row(choice, row):
+        put_text("You click %s button at row %s" % (choice, row), after='table_cell_buttons')
+
+    put_table([
+        ['Idx', 'Actions'],
+        ['1', table_cell_buttons(['edit', 'delete'], onclick=partial(edit_row, row=1))],
+        ['2', table_cell_buttons(['edit', 'delete'], onclick=partial(edit_row, row=2))],
+        ['3', table_cell_buttons(['edit', 'delete'], onclick=partial(edit_row, row=3))],
+    ], anchor='table_cell_buttons')
+
+    put_buttons(['A', 'B', 'C'], onclick=partial(put_text, after='put_buttons'), anchor='put_buttons')
+
+    img_data = open(path.join(here_dir, 'assets', 'img.png'), 'rb').read()
+    put_image(img_data, anchor='put_image1')
+    put_image(img_data, width="30px", anchor='put_image2')
+    put_image(img_data, height="50px", anchor='put_image3')
+
+    put_file('hello_word.txt', b'hello word!', anchor='put_file')
+
+    put_markdown('### 锚点')
+
+    put_text('anchor A1', anchor='A1')
+    put_text('new anchor A1', anchor='A1')
+    put_text('anchor A2', anchor='A2')
+    put_text('anchor A3', anchor='A3')
+
+    put_text('after=A1', after='A1')
+    put_text('after=A2', after='A2')
+    put_text('before=A1', before='A1')
+    put_text('before=A3', before='A3')
+    put_text('after=A3', after='A3')
+
+    clear_range('A1', "A2")
+    clear_range('A3', 'A2')
+    clear_after('A3')
+
+    put_text('before=top', before='top')
+    clear_before('top')
+    put_text('before=top again', before='top')
+
+    put_text('to remove', anchor='to_remove')
+    remove('to_remove')
+
+
+def background_output():
+    put_text("Background output", anchor='background')
+
+    def background():
+        for i in range(20):
+            put_text('%s ' % i, inline=True, after='background')
+
+    t = threading.Thread(target=background)
+    register_thread(t)
+    t.start()
+
+
+async def coro_background_output():
+    put_text("Background output", anchor='background')
+
+    async def background():
+        for i in range(20):
+            put_text('%s ' % i, inline=True, after='background')
+
+    return run_async(background())
+
+
+def test_output(browser: Chrome, percy_prefix=''):
+    """测试输出::
+
+        template.basic_output()
+        template.background_output() # 或者 await template.coro_background_output()
+        hold()
+
+    """
+    time.sleep(1)  # 等待输出完毕
+
+    if percy_prefix:
+        percy_prefix = percy_prefix + ' '
+
+    tab_btns = browser.find_elements_by_css_selector('#pywebio-anchor-table_cell_buttons button')
+    for btn in tab_btns:
+        time.sleep(0.5)
+        btn.click()
+
+    btns = browser.find_elements_by_css_selector('#pywebio-anchor-put_buttons button')
+    for btn in btns:
+        time.sleep(0.5)
+        btn.click()
+
+    time.sleep(1)
+    percySnapshot(browser=browser, name=percy_prefix + 'basic output')
+
+
+def basic_input():
+    age = yield input("How old are you?", type=NUMBER)
+    put_markdown(f'`{repr(age)}`')
+
+    password = yield input("Input password", type=PASSWORD)
+    put_markdown(f'`{repr(password)}`')
+
+    # 下拉选择框
+    gift = yield select('Which gift you want?', ['keyboard', 'ipad'])
+    put_markdown(f'`{repr(gift)}`')
+
+    # CheckBox
+    agree = yield checkbox("用户协议", options=['I agree to terms and conditions'])
+    put_markdown(f'`{repr(agree)}`')
+
+    # Text Area
+    text = yield textarea('Text Area', rows=3, placeholder='Some text')
+    put_markdown(f'`{repr(text)}`')
+
+    # 文件上传
+    img = yield file_upload("Select a image:", accept="image/*")
+    put_markdown(f'`{repr(img)}`')
+
+    # 输入参数
+    res = yield input('This is label', type=TEXT, placeholder='This is placeholder,required=True',
+                      help_text='This is help text', required=True)
+    put_markdown(f'`{repr(res)}`')
+
+    # 校验函数
+    def check_age(p):  # 检验函数校验通过时返回None,否则返回错误消息
+        if p < 10:
+            return 'Too young!!'
+        if p > 60:
+            return 'Too old!!'
+
+    age = yield input("How old are you?", type=NUMBER, valid_func=check_age, help_text='age in [10, 60]')
+    put_markdown(f'`{repr(age)}`')
+
+    # Codemirror
+    code = yield textarea('Code Edit', code={
+        'mode': "python",  # 编辑区代码语言
+        'theme': 'darcula',  # 编辑区darcula主题, Visit https://codemirror.net/demo/theme.html#cobalt to get more themes
+    }, value='import something\n# Write your python code')
+    put_markdown(f'`{repr(code)}`')
+
+    # 输入组
+    info = yield input_group("Cancelable", [
+        input('Input your name', name='name'),
+        input('Input your age', name='age', type=NUMBER, valid_func=check_age, help_text='age in [10, 60]')
+    ], cancelable=True)
+    put_markdown(f'`{repr(info)}`')
+
+    def check_form(data):  # 检验函数校验通过时返回None,否则返回 (input name,错误消息)
+        if len(data['password']) > 6:
+            return ('password', 'password太长!')
+
+    check_item_data = []
+
+    def check_item(data):
+        check_item_data.append(repr(data))
+
+    info = yield input_group('Input group', [
+        input('Text', type=TEXT, datalist=['data-%s' % i for i in range(10)], name='text',
+              required=True, help_text='required=True', valid_func=check_item),
+        input('Number', type=NUMBER, value="42", name='number', valid_func=check_item),
+        input('Float', type=FLOAT, name='float', valid_func=check_item),
+        input('Password', type=PASSWORD, name='password', valid_func=check_item),
+
+        textarea('Textarea', rows=3, maxlength=20, name='textarea',
+                 help_text='rows=3, maxlength=20', valid_func=check_item),
+
+        textarea('Code', name='code', code={
+            'lineNumbers': False,
+            'indentUnit': 2,
+        }, value='import something\n# Write your python code', valid_func=check_item),
+
+        select('select-multiple', [
+            {'label': '标签0,selected', 'value': '0', 'selected': True},
+            {'label': '标签1,disabled', 'value': '1', 'disabled': True},
+            ('标签2,selected', '2', True),
+            ('标签3', '3'),
+            ('标签4,disabled', '4', False, True),
+            '标签5,selected',
+        ], name='select-multiple', multiple=True, value=['标签5,selected'], required=True,
+               help_text='required至少选择一项', valid_func=check_item),
+
+        select('select', [
+            {'label': '标签0', 'value': '0', 'selected': False},
+            {'label': '标签1,disabled', 'value': '1', 'disabled': True},
+            ('标签2', '2', False),
+            ('标签3', '3'),
+            ('标签4,disabled', '4', False, True),
+            '标签5,selected',
+        ], name='select', value=['标签5,selected'], valid_func=check_item),
+
+        checkbox('checkbox-inline', [
+            {'label': '标签0,selected', 'value': '0', 'selected': False},
+            {'label': '标签1,disabled', 'value': '1', 'disabled': True},
+            ('标签2,selected', '2', True),
+            ('标签3', '3'),
+            ('标签4,disabled', '4', False, True),
+            '标签5,selected',
+        ], inline=True, name='checkbox-inline', value=['标签5,selected', '标签0', '标签0,selected'], valid_func=check_item),
+
+        checkbox('checkbox', [
+            {'label': '标签0,selected', 'value': '0', 'selected': True},
+            {'label': '标签1,disabled', 'value': '1', 'disabled': True},
+            ('标签2,selected', '2', True),
+            ('标签3', '3'),
+            ('标签4,disabled', '4', False, True),
+            '标签5',
+        ], name='checkbox', valid_func=check_item),
+
+        radio('radio-inline', [
+            {'label': '标签0', 'value': '0', 'selected': False},
+            {'label': '标签1,disabled', 'value': '1', 'disabled': True},
+            ('标签2', '2', False),
+            ('标签3', '3'),
+            ('标签4,disabled', '4', False, True),
+            '标签5,selected',
+        ], inline=True, name='radio-inline', value='标签5,selected', valid_func=check_item),
+
+        radio('radio', [
+            {'label': '标签0', 'value': '0', 'selected': False},
+            {'label': '标签1,disabled', 'value': '1', 'disabled': True},
+            ('标签2', '2', False),
+            ('标签3', '3'),
+            ('标签4,disabled', '4', False, True),
+            '标签5,selected',
+        ], inline=False, name='radio', value='标签5,selected', valid_func=check_item),
+
+        file_upload('file_upload', name='file_upload'),
+
+        actions('actions', [
+            {'label': '提交', 'value': 'submit'},
+            ('提交2', 'submit2'),
+            '提交3',
+            {'label': 'disabled', 'disabled': True},
+            ('重置', 'reset', 'reset'),
+            {'label': '取消', 'type': 'cancel'},
+        ], name='actions', help_text='actions'),
+
+    ], valid_func=check_form)
+
+    put_text('`valid_func()` log:')
+    put_code(json.dumps(sorted(check_item_data), indent=4, ensure_ascii=False), 'json')
+
+    put_text('Form result:')
+    if info:
+        put_code(json.dumps([repr(i) for i in sorted(info.items())], indent=4, ensure_ascii=False), 'json')
+
+    # yield actions(['Continue'])
+
+
+def background_input():
+    def background():
+        time.sleep(1)
+        res = input('background')
+        put_markdown(f'`background: {repr(res)}`')
+
+    t = threading.Thread(target=background)
+    register_thread(t)
+    t.start()
+
+    res = input('front')
+    put_markdown(f'`front: {repr(res)}`')
+
+
+async def coro_background_input():
+    async def background():
+        await asyncio.sleep(1)
+        res = await input('background')
+        put_markdown(f'`background: {repr(res)}`')
+
+    run_async(background())
+
+    res = await input('front')
+    put_markdown(f'`front: {repr(res)}`')
+
+
+async def flask_coro_background_input():
+    async def background():
+        await run_asyncio_coroutine(asyncio.sleep(1))
+        res = await input('background')
+        put_markdown(f'`background: {repr(res)}`')
+
+    run_async(background())
+
+    res = await input('front')
+    put_markdown(f'`front: {repr(res)}`')
+
+
+def test_input(browser: Chrome, percy_prefix=''):
+    """测试输入::
+
+        template.basic_input()
+        actions(['Continue'])
+        template.background_input() # 或者 await template.coro_background_input() / flask_coro_background_input
+
+    """
+    if percy_prefix:
+        percy_prefix = percy_prefix + ' '
+
+    browser.find_element_by_css_selector('input').send_keys("22")
+    browser.find_element_by_tag_name('form').submit()
+
+    time.sleep(0.5)
+    browser.find_element_by_css_selector('input').send_keys("secret")
+    browser.find_element_by_tag_name('form').submit()
+
+    time.sleep(0.5)
+    browser.find_element_by_tag_name('form').submit()
+
+    # checkbox
+    time.sleep(0.5)
+    browser.find_element_by_css_selector('input').click()
+    browser.find_element_by_tag_name('form').submit()
+
+    # Text Area
+    time.sleep(0.5)
+    browser.find_element_by_css_selector('textarea').send_keys(" ".join(str(i) for i in range(20)))
+    browser.find_element_by_tag_name('form').submit()
+
+    # file
+    time.sleep(0.5)
+    img_path = path.join(here_dir, 'assets', 'img.png')
+    browser.find_element_by_css_selector('input').send_keys(img_path)
+    browser.find_element_by_tag_name('form').submit()
+
+    # text
+    time.sleep(0.5)
+    browser.find_element_by_css_selector('input').send_keys("text")
+    browser.find_element_by_tag_name('form').submit()
+
+    # valid func, age in [10, 60]
+    time.sleep(0.5)
+    browser.find_element_by_css_selector('input').send_keys("1")
+    browser.find_element_by_tag_name('form').submit()
+    time.sleep(0.5)
+    browser.find_element_by_css_selector('input').clear()
+    browser.find_element_by_css_selector('input').send_keys("90")
+    browser.find_element_by_tag_name('form').submit()
+    time.sleep(0.5)
+    browser.find_element_by_css_selector('input').clear()
+    browser.find_element_by_css_selector('input').send_keys("23")
+    browser.find_element_by_tag_name('form').submit()
+
+    # code
+    time.sleep(0.5)
+    # browser.find_element_by_css_selector('textarea').send_keys(" ".join(str(i) for i in range(20)))
+    browser.find_element_by_tag_name('form').submit()
+
+    # Cancelable from group
+    time.sleep(0.5)
+    browser.find_element_by_css_selector('input[name="name"]').send_keys("name")
+    browser.find_element_by_css_selector('input[name="age"]').send_keys("90")
+    browser.find_element_by_tag_name('form').submit()
+    percySnapshot(browser=browser, name=percy_prefix + 'input group invalid')
+    browser.find_element_by_css_selector('input[name="age"]').clear()
+    browser.find_element_by_css_selector('input[name="age"]').send_keys("23")
+    browser.find_element_by_tag_name('form').submit()
+
+    # Input group
+    time.sleep(0.5)
+    percySnapshot(browser=browser, name=percy_prefix + 'input group all')
+    browser.find_element_by_css_selector('input[name="text"]').send_keys("name")
+    browser.find_element_by_css_selector('input[name="number"]').send_keys("20")
+    browser.find_element_by_css_selector('input[name="float"]').send_keys("3.1415")
+    browser.find_element_by_css_selector('input[name="password"]').send_keys("password")
+    browser.find_element_by_css_selector('textarea[name="textarea"]').send_keys(" ".join(str(i) for i in range(20)))
+    # browser.find_element_by_css_selector('[name="code"]').send_keys(" ".join(str(i) for i in range(10)))
+    Select(browser.find_element_by_css_selector('select[name="select-multiple"]')).select_by_index(0)
+    # browser. find_element_by_css_selector('[name="select"]'). send_keys("name")
+    # browser. find_element_by_css_selector('[name="checkbox-inline"]'). send_keys("name")
+    # browser. find_element_by_css_selector('[name="checkbox"]'). send_keys("name")
+    # browser. find_element_by_css_selector('[name="radio-inline"]'). send_keys("name")
+    # browser. find_element_by_css_selector('[name="radio"]'). send_keys("name")
+    browser.find_element_by_css_selector('input[name="file_upload"]').send_keys(img_path)
+
+    browser.find_element_by_css_selector('button[value="submit2"]').click()
+    time.sleep(0.5)
+    percySnapshot(browser=browser, name=percy_prefix + 'input group all invalid')
+
+    browser.find_element_by_css_selector('input[name="password"]').clear()
+    browser.find_element_by_css_selector('input[name="password"]').send_keys("123")
+    browser.find_element_by_css_selector('button[value="submit2"]').click()
+    time.sleep(0.5)
+    percySnapshot(browser=browser, name=percy_prefix + 'input group all submit')
+
+    browser.find_element_by_css_selector('form').submit()
+
+    # background
+    time.sleep(3)
+    get_visible_form(browser).find_element_by_css_selector('input').send_keys("background")
+    get_visible_form(browser).find_element_by_tag_name('form').submit()
+    # front
+    time.sleep(0.5)
+    get_visible_form(browser).find_element_by_css_selector('input').send_keys("front")
+    get_visible_form(browser).find_element_by_tag_name('form').submit()
+
+
+def set_defer_call():
+    def deferred_1():
+        open('test_defer.tmp', 'w').write('deferred_1')
+
+    def deferred_2():
+        open('test_defer.tmp', 'a').write('deferred_2')
+
+    defer_call(deferred_1)
+    defer_call(deferred_2)
+
+
+def test_defer_call():
+    output = open('test_defer.tmp').read()
+    assert "deferred_1" in output
+    assert "deferred_2" in output
+
+    os.remove('test_defer.tmp')

+ 71 - 0
test/util.py

@@ -0,0 +1,71 @@
+import asyncio
+import signal
+import subprocess
+import sys
+
+from selenium import webdriver
+
+from pywebio.utils import wait_host_port
+
+default_chrome_options = webdriver.ChromeOptions()
+default_chrome_options.add_argument('--no-sandbox')
+default_chrome_options.add_argument('--disable-extensions')
+default_chrome_options.add_argument('--disable-dev-shm-usage')
+default_chrome_options.add_argument('--disable-setuid-sandbox')
+
+USAGE = """
+python {name}
+    启动PyWebIO服务器
+
+python {name} auto
+    使用无头浏览器进行自动化测试,并使用coverage检测代码覆盖率
+
+python {name} debug
+    使用带界面的浏览器自动化测试
+"""
+
+
+def run_test(server_func, test_func, port=8080, chrome_options=None):
+    """
+    :param server_func: 启动PyWebIO服务器的函数
+    :param test_func: 测试的函数。人工测试时不会被运行 (server_proc, browser)
+    :param port: 启动的PyWebIO服务器的端口
+    """
+    if len(sys.argv) not in (1, 2) or (len(sys.argv) == 2 and sys.argv[-1] not in ('server', 'auto', 'debug')):
+        print(USAGE.format(name=sys.argv[0]))
+        return
+
+    if len(sys.argv) != 2:
+        try:
+            server_func()
+        except KeyboardInterrupt:
+            pass
+        sys.exit()
+
+    if chrome_options is None:
+        chrome_options = default_chrome_options
+
+    if sys.argv[-1] == 'auto':
+        default_chrome_options.add_argument('--headless')
+        proc = subprocess.Popen(['coverage', 'run', '--source', 'pywebio',
+                                 sys.argv[0]], stdout=sys.stdout, stderr=subprocess.STDOUT, text=True)
+    elif sys.argv[-1] == 'debug':
+        proc = subprocess.Popen(['python3', sys.argv[0]], stdout=sys.stdout, stderr=subprocess.STDOUT, text=True)
+
+    browser = None
+    try:
+        browser = webdriver.Chrome(chrome_options=chrome_options)
+        asyncio.run(wait_host_port('localhost', port))
+        browser.get('http://localhost:%s?_pywebio_debug=1' % port)
+        browser.implicitly_wait(10)
+        test_func(proc, browser)
+    finally:
+        if browser:
+            if sys.argv[-1] == 'debug':
+                input('press ENTER to exit')
+
+            browser.quit()
+
+        # 不要使用 proc.terminate() ,因为coverage会无法保存分析数据
+        proc.send_signal(signal.SIGINT)
+        print("Closed browser and PyWebIO server")