Explorar el Código

Merge pull request #5 from wang0618/dev

Release version 0.2.0
WangWeimin hace 5 años
padre
commit
0adc994926
Se han modificado 54 ficheros con 1932 adiciones y 476 borrados
  1. 29 0
      .drone.yml
  2. 25 0
      .github/workflows/release.yml
  3. 31 6
      .github/workflows/test.yml
  4. 0 2
      .readthedocs.yml
  5. 1 0
      Procfile
  6. 9 7
      README.md
  7. 6 0
      demos/__init__.py
  8. 63 0
      demos/__main__.py
  9. 38 0
      demos/bmi.py
  10. 80 0
      demos/chat_room.py
  11. 5 0
      demos/config.py
  12. 151 0
      demos/input_usage.py
  13. 148 0
      demos/output_usage.py
  14. 19 0
      docs/conf.py
  15. 12 0
      docs/demos.rst
  16. 106 52
      docs/guide.rst
  17. 9 2
      docs/index.rst
  18. 52 0
      docs/libraries_support.rst
  19. 7 0
      docs/releases.rst
  20. 49 0
      docs/releases/v0.2.0.rst
  21. 18 0
      docs/static/pywebio.css
  22. 2 2
      pywebio/__version__.py
  23. 3 1
      pywebio/html/js/pywebio.js
  24. 5 1
      pywebio/output.py
  25. 14 10
      pywebio/platform/__init__.py
  26. 188 0
      pywebio/platform/aiohttp.py
  27. 200 0
      pywebio/platform/django.py
  28. 60 172
      pywebio/platform/flask.py
  29. 227 0
      pywebio/platform/httpbased.py
  30. 21 15
      pywebio/platform/tornado.py
  31. 14 1
      pywebio/session/__init__.py
  32. 13 9
      pywebio/session/coroutinebased.py
  33. 2 2
      pywebio/session/threadbased.py
  34. 8 1
      pywebio/utils.py
  35. 13 0
      requirements.txt
  36. 10 8
      setup.py
  37. 3 0
      test/.percy.yml
  38. 13 5
      test/1.basic.py
  39. 0 33
      test/1.basic_output.py
  40. 62 0
      test/10.aiohttp_multiple_session_impliment.py
  41. 6 9
      test/2.script_mode.py
  42. 41 0
      test/3.django_backend.py
  43. 6 10
      test/4.flask_backend.py
  44. 5 7
      test/5.coroutine_based_session.py
  45. 5 7
      test/6.flask_coroutine.py
  46. 8 20
      test/7.multiple_session_impliment.py
  47. 8 21
      test/8.flask_multiple_session_impliment.py
  48. 41 0
      test/9.aiohttp_backend.py
  49. 1 0
      test/assets/helloworld.txt
  50. 22 0
      test/output_diff.py
  51. 0 24
      test/run_all.py
  52. 12 9
      test/run_all.sh
  53. 60 40
      test/template.py
  54. 1 0
      test/util.py

+ 29 - 0
.drone.yml

@@ -0,0 +1,29 @@
+kind: pipeline
+type: exec
+name: default
+
+clone:
+  disable: true
+
+trigger:
+  event:
+    - push
+
+steps:
+  - name: clone
+    commands:
+      - git config --global http.https://github.com.proxy socks5://127.0.0.1:1080
+      - git config --global https.https://github.com.proxy socks5://127.0.0.1:1080
+      - git init
+      - git remote add origin $DRONE_GIT_HTTP_URL
+      - git fetch --no-tags --prune --progress --no-recurse-submodules --depth=1 origin +$DRONE_COMMIT:refs/remotes/origin/$DRONE_BRANCH
+      - git checkout --progress --force -B $DRONE_BRANCH refs/remotes/origin/$DRONE_BRANCH
+      - git log -1
+  - name: deploy demos
+    commands:
+      - docker rm -f pywebio-demos || true
+      - >
+        docker run --restart=always --name=pywebio-demos -v $PWD:/app_tmp
+        --label="traefik.http.services.pywebiodemos.loadbalancer.server.port=80"
+        -d python:3 bash -c "cp -r /app_tmp /app && cd /app && pip3 install -i https://pypi.tuna.tsinghua.edu.cn/simple . && python3 -m demos --port=80"
+      - sleep 5  # wait container start

+ 25 - 0
.github/workflows/release.yml

@@ -0,0 +1,25 @@
+name: Release to PyPi
+
+on:
+  create:
+    tags:
+      - v*
+
+jobs:
+  release:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@master
+      - name: Set up Python 3
+        uses: actions/setup-python@v1
+        with:
+          python-version: 3.7
+      - run: |
+          pip3 install twine
+          python3 setup.py sdist
+          twine upload --username "__token__" --disable-progress-bar --verbose dist/*
+        env:
+          TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
+
+

+ 31 - 6
.github/workflows/test.yml

@@ -1,4 +1,4 @@
-name: Tests
+name: Tests and Sync
 on: [push, pull_request]
 jobs:
   test:
@@ -12,15 +12,40 @@ jobs:
           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: Install package
+        run: pip3 install -e ".[all]"
+      - name: Install dev dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install -r requirements.txt
       - name: Percy Test
         uses: percy/exec-action@v0.2.0
         with:
-          command: "test/run_all.sh"
+          working-directory: ./test
+          command: "./run_all.sh"
         env:
           PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}
+      - name: Upload test output
+        uses: actions/upload-artifact@v1
+        if: failure()
+        with:
+          name: test output
+          path: test/output
       - name: Upload Codecov Report
+        working-directory: ./test
+        run: bash <(curl -s https://codecov.io/bash)
+  sync:
+    runs-on: ubuntu-latest
+    if: github.event_name == 'push'
+    steps:
+      - name: Checkout
+        uses: actions/checkout@master
+      - name: Push
         run: |
-          cd test
-          bash <(curl -s https://codecov.io/bash)
+          git fetch --unshallow origin
+          git remote add aliyun "https://code.aliyun.com/wang0618/pywebio.git"
+          git config credential.helper '!f() { sleep 1; echo "username=${ALIYUN_GIT_USER}"; echo "password=${ALIYUN_GIT_PASSWORD}"; }; f'
+          git push -f -u aliyun
+        env:
+          ALIYUN_GIT_USER: ${{ secrets.ALIYUN_GIT_USER }}
+          ALIYUN_GIT_PASSWORD: ${{ secrets.ALIYUN_GIT_PASSWORD }}

+ 0 - 2
.readthedocs.yml

@@ -23,5 +23,3 @@ python:
     - requirements: requirements.txt
     - method: pip
       path: .
-      extra_requirements:
-        - flask

+ 1 - 0
Procfile

@@ -0,0 +1 @@
+web: python -m demos --port=$PORT

+ 9 - 7
README.md

@@ -1,6 +1,6 @@
 <h1 align="center">PyWebIO</h1>
 <p align="center">
-    <em>Write web app in script way.</em>
+    <em>Write interactive web app in script way.</em>
 </p>
 <p align="center">
     <a href="https://percy.io/pywebio/pywebio">
@@ -12,15 +12,17 @@
     <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 href="https://pypi.org/project/PyWebIO/">
+        <img src="https://img.shields.io/pypi/v/pywebio?colorB=brightgreen" 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">
+        <img src="https://img.shields.io/github/license/wang0618/PyWebIO.svg" alt="License">
     </a>
+    <br/>
+    <a href="https://pywebio.readthedocs.io">[Document]</a> | <a href="http://pywebio-demos.wangweimin.site/">[Demos]</a>
 </p>
 
 PyWebIO是一个用于在浏览器上获取输入和进行输出的工具库。能够将原有的通过终端交互的脚本快速服务化,供其他人在网络上通过浏览器访问使用;
@@ -32,7 +34,7 @@ PyWebIO还可以方便地整合进现有的Web服务,让你不需要编写Html
 - 代码侵入性小,对于旧脚本代码仅需修改输入输出逻辑
 - 支持多用户与并发请求
 - 支持结合第三方库实现数据可视化
-- 支持整合到现有的Web服务,目前支持与Tornado和Flask的集成
+- 支持整合到现有的Web服务,目前支持与Flask、Django、Tornado、aiohttp框架集成
 - 同时支持基于线程的执行模型和基于协程的执行模型
 
 
@@ -105,7 +107,7 @@ def bmi():
 if __name__ == '__main__':
     start_server(bmi)
 ```
-
+[[demo]](http://pywebio-demos.wangweimin.site/?pywebio_api=bmi)
 
 
 **与现有Web框架整合**
@@ -137,7 +139,7 @@ if __name__ == "__main__":
 ## Demos
 
  - [数据可视化demo](http://pywebio-charts.wangweimin.site/) : 使用 plotly、pyecharts 等库创建图表
- - [其他demo](https://pywebio.herokuapp.com/) : 包含PyWebIO基本输入输出演示和使用PyWebIO编写的小应用
+ - [其他demo](http://pywebio-demos.wangweimin.site/) : 包含PyWebIO基本输入输出演示和使用PyWebIO编写的小应用
 
 ## Document
 

+ 6 - 0
demos/__init__.py

@@ -0,0 +1,6 @@
+r"""
+.. automodule:: demos.bmi
+.. automodule:: demos.input_usage
+.. automodule:: demos.output_usage
+.. automodule:: demos.chat_room
+"""

+ 63 - 0
demos/__main__.py

@@ -0,0 +1,63 @@
+import tornado.ioloop
+import tornado.web
+
+from demos.bmi import main as bmi
+from demos.chat_room import main as chat_room
+from demos.input_usage import main as input_usage
+from demos.output_usage import main as output_usage
+from demos.config import charts_demo_host
+
+from pywebio import STATIC_PATH
+from pywebio.output import put_markdown, set_auto_scroll_bottom
+from pywebio.platform.tornado import webio_handler
+from tornado.options import define, options
+
+index_md = r"""# PyWebIO demos
+### 基本demo
+
+ - [BMI计算](./?pywebio_api=bmi): 根据身高体重计算BMI指数 [源码](https://github.com/wang0618/PyWebIO/blob/master/demos/bmi.py)
+ - [聊天室](./?pywebio_api=chat_room): 和当前所有在线的人聊天 [源码](https://github.com/wang0618/PyWebIO/blob/master/demos/chat_room.py)
+ - [输入演示](./?pywebio_api=input_usage):  演示PyWebIO输入模块的用法 [源码](https://github.com/wang0618/PyWebIO/blob/master/demos/input_usage.py)
+ - [输出演示](./?pywebio_api=output_usage): 演示PyWebIO输出模块的用法 [源码](https://github.com/wang0618/PyWebIO/blob/master/demos/output_usage.py)
+
+### 数据可视化demo
+PyWebIO还支持使用第三方库进行数据可视化
+
+ - 使用`pyecharts`创建基于Echarts的图表 [**demos**]({charts_demo_host}/?pywebio_api=pyecharts)
+ - 使用`cutecharts.py`创建卡通风格图表 [**demos**]({charts_demo_host}/?pywebio_api=cutecharts)
+ - 使用`plotly`进行数据可视化 [**demos**]({charts_demo_host}/?pywebio_api=plotly)
+
+**数据可视化demo截图**
+
+![pyecharts](https://cdn.jsdelivr.net/gh/wang0618/pywebio-chart-gallery@master/assets/pyecharts.gif)
+
+![cutecharts](https://cdn.jsdelivr.net/gh/wang0618/pywebio-chart-gallery@master/assets/cutecharts.png)
+
+![plotly](https://cdn.jsdelivr.net/gh/wang0618/pywebio-chart-gallery@master/assets/plotly.png)
+
+### Links
+* PyWebIO Github [github.com/wang0618/PyWebIO](https://github.com/wang0618/PyWebIO)
+* 使用手册和实现文档见 [pywebio.readthedocs.io](https://pywebio.readthedocs.io)
+
+""".format(charts_demo_host=charts_demo_host)
+
+
+def index():
+    set_auto_scroll_bottom(False)
+    put_markdown(index_md)
+
+
+if __name__ == "__main__":
+    define("port", default=8080, help="run on the given port", type=int)
+    tornado.options.parse_command_line()
+
+    application = tornado.web.Application([
+        (r"/io", webio_handler(index)),
+        (r"/bmi", webio_handler(bmi)),
+        (r"/chat_room", webio_handler(chat_room)),
+        (r"/input_usage", webio_handler(input_usage)),
+        (r"/output_usage", webio_handler(output_usage)),
+        (r"/(.*)", tornado.web.StaticFileHandler, {"path": STATIC_PATH, 'default_filename': 'index.html'})
+    ])
+    application.listen(port=options.port)
+    tornado.ioloop.IOLoop.current().start()

+ 38 - 0
demos/bmi.py

@@ -0,0 +1,38 @@
+"""
+BMI指数计算
+^^^^^^^^^^^
+
+计算 `BMI指数 <https://en.wikipedia.org/wiki/Body_mass_index>`_ 的简单应用
+
+:demo_host:`Demo地址 </?pywebio_api=bmi>`  `源码 <https://github.com/wang0618/PyWebIO/blob/master/demos/bmi.py>`_
+"""
+from pywebio import start_server
+from pywebio.input import *
+from pywebio.output import *
+
+
+def main():
+    set_output_fixed_height(True)
+    set_title("BMI Calculation")
+
+    put_markdown("""计算 [`BMI指数`](https://baike.baidu.com/item/%E4%BD%93%E8%B4%A8%E6%8C%87%E6%95%B0/1455733) 的简单应用,源代码[链接](https://github.com/wang0618/PyWebIO/blob/master/demos/bmi.py)""", lstrip=True)
+
+    info = input_group('请输入', [
+        input("请输入你的身高(cm)", name="height", type=FLOAT),
+        input("请输入你的体重(kg)", name="weight", type=FLOAT),
+    ])
+
+    BMI = info['weight'] / (info['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_markdown('你的 BMI 值: `%.1f`,身体状态:`%s`' % (BMI, status))
+            break
+
+
+if __name__ == '__main__':
+    start_server(main, debug=True, port=8080)

+ 80 - 0
demos/chat_room.py

@@ -0,0 +1,80 @@
+"""
+聊天室
+^^^^^^^^^^^
+和当前所有在线的人聊天
+
+:demo_host:`Demo地址 </?pywebio_api=chat_room>`  `源码 <https://github.com/wang0618/PyWebIO/blob/master/demos/chat_room.py>`_
+
+* 使用基于协程的会话
+* 使用 `run_async() <pywebio.session.run_async>` 启动后台协程
+"""
+import asyncio
+
+from pywebio import start_server, run_async
+from pywebio.input import *
+from pywebio.output import *
+from pywebio.session import defer_call
+
+# 最大消息记录保存
+MAX_MESSAGES_CNT = 10 ** 4
+
+chat_msgs = []  # 聊天记录 (name, msg)
+online_users = set()  # 在线用户
+
+
+async def refresh_msg(my_name):
+    """刷新聊天消息"""
+    global chat_msgs
+    last_idx = len(chat_msgs)
+    while True:
+        await asyncio.sleep(0.5)
+        for m in chat_msgs[last_idx:]:
+            if m[0] != my_name:  # 仅刷新其他人的新信息
+                put_markdown('`%s`: %s' % m)
+
+        # 清理聊天记录
+        if len(chat_msgs) > MAX_MESSAGES_CNT:
+            chat_msgs = chat_msgs[len(chat_msgs) // 2:]
+
+        last_idx = len(chat_msgs)
+
+
+async def main():
+    global chat_msgs
+
+    set_output_fixed_height(True)
+    set_title("PyWebIO Chat Room")
+    put_markdown("""欢迎来到聊天室,你可以和当前所有在线的人聊天\n
+    本应用使用不到80行代码实现,源代码[链接](https://github.com/wang0618/PyWebIO/blob/master/demos/chat_room.py)""", lstrip=True)
+
+    nickname = await input("请输入你的昵称", required=True,
+                           valid_func=lambda n: '昵称已被使用' if n in online_users or n == '📢' else None)
+
+    online_users.add(nickname)
+    chat_msgs.append(('📢', '`%s`加入聊天室. 当前在线人数 %s' % (nickname, len(online_users))))
+    put_markdown('`📢`: `%s`加入聊天室. 当前在线人数 %s' % (nickname, len(online_users)))
+
+    @defer_call
+    def on_close():
+        online_users.remove(nickname)
+        chat_msgs.append(('📢', '`%s`退出聊天室. 当前在线人数 %s' % (nickname, len(online_users))))
+
+    refresh_task = run_async(refresh_msg(nickname))
+
+    while True:
+        data = await input_group('发送消息', [
+            input(name='msg', help_text='消息内容支持Markdown 语法', required=True),
+            actions(name='cmd', buttons=['发送', {'label': '退出', 'type': 'cancel'}])
+        ])
+        if data is None:
+            break
+
+        put_markdown('`%s`: %s' % (nickname, data['msg']))
+        chat_msgs.append((nickname, data['msg']))
+
+    refresh_task.close()
+    put_text("你已经退出聊天室")
+
+
+if __name__ == '__main__':
+    start_server(main, debug=True, port=8080)

+ 5 - 0
demos/config.py

@@ -0,0 +1,5 @@
+# demos 模块的部署地址
+demo_host = 'http://pywebio-demos.wangweimin.site'
+
+# https://github.com/wang0618/pywebio-chart-gallery 的部署地址
+charts_demo_host = 'http://pywebio-charts.wangweimin.site'

+ 151 - 0
demos/input_usage.py

@@ -0,0 +1,151 @@
+"""
+输入演示
+^^^^^^^^^^^
+演示PyWebIO支持的各种输入形式
+
+:demo_host:`Demo地址 </?pywebio_api=input_usage>`  `源码 <https://github.com/wang0618/PyWebIO/blob/master/demos/input_usage.py>`_
+"""
+from pywebio import start_server
+from pywebio.input import *
+from pywebio.output import *
+
+
+def main():
+    set_auto_scroll_bottom(False)
+    set_title("PyWebIO输入演示")
+
+    put_markdown("""# PyWebIO 输入演示
+    
+    在[这里](https://github.com/wang0618/PyWebIO/blob/master/demos/input_usage.py)可以获取本Demo的源码。
+    
+    PyWebIO的输入函数都定义在 `pywebio.input` 模块中,可以使用 `from pywebio.input import *` 引入。
+
+    ### 基本输入
+    首先是一些基本类型的输入
+
+    #### 文本输入
+    ```python
+    name = input("What's your name?")
+    ```
+    """, lstrip=True)
+    put_text("这样一行代码的效果如下:", anchor='input-1')
+    name = input("What's your name?")
+    put_markdown("`name = %r`" % name, anchor='input-1')
+
+    # 其他类型的输入
+    put_markdown("""PyWebIO的输入函数是同步的,在表单被提交之前,输入函数不会返回。
+    #### 其他类型的输入:
+    ```python
+    # 密码输入
+    password = input("Input password", type=PASSWORD)
+    
+    # 下拉选择框
+    gift = select('Which gift you want?', ['keyboard', 'ipad'])
+    
+    # CheckBox
+    agree = checkbox("用户协议", options=['I agree to terms and conditions'])
+    
+    # Text Area
+    text = textarea('Text Area', rows=3, placeholder='Some text')
+    
+    # 文件上传
+    img = file_upload("Select a image:", accept="image/*")
+    ```
+    """, lstrip=True)
+    password = input("Input password", type=PASSWORD)
+    put_markdown("`password = %r`" % password)
+    gift = select('Which gift you want?', ['keyboard', 'ipad'])
+    put_markdown("`gift = %r`" % gift)
+    agree = checkbox("用户协议", options=['I agree to terms and conditions'])
+    put_markdown("`agree = %r`" % agree)
+    text = textarea('Text Area', rows=3, placeholder='Some text')
+    put_markdown("`text = %r`" % text)
+    img = file_upload("Select a image:", accept="image/*", help_text='可以直接选择"提交"')
+    put_markdown("`img = %r`" % img)
+
+    # 输入选项
+    put_markdown("""#### 输入选项
+    输入函数可指定的参数非常丰富:
+    ```python
+    input('This is label', type=TEXT, placeholder='This is placeholder', 
+          help_text='This is help text', required=True, 
+          datalist=['candidate1', 'candidate2', 'candidate2'])
+    ```
+    """, strip_indent=4)
+    input('This is label', type=TEXT, placeholder='This is placeholder',
+          help_text='This is help text', required=True,
+          datalist=['candidate1', 'candidate2', 'candidate2'])
+
+    # 校验函数
+    put_markdown("""我们可以为输入指定校验函数,校验函数校验通过时返回None,否则返回错误消息:
+    ```python
+    def check_age(p):  # 检验函数校验通过时返回None,否则返回错误消息
+        if p < 10:
+            return 'Too young!!'
+        if p > 60:
+            return 'Too old!!'
+
+    age = input("How old are you?", type=NUMBER, valid_func=check_age)
+    ```
+    """, strip_indent=4)
+
+    def check_age(p):  # 检验函数校验通过时返回None,否则返回错误消息
+        if p < 10:
+            return 'Too young!!'
+        if p > 60:
+            return 'Too old!!'
+
+    age = input("How old are you?", type=NUMBER, valid_func=check_age, help_text='尝试输入一些非法值,比如"8"、"65"')
+    put_markdown('`age = %r`' % age)
+
+    # Codemirror
+    put_markdown(r"""PyWebIO 的 `textarea()` 输入函数还支持使用 [Codemirror](https://codemirror.net/) 实现代码风格的编辑区,只需使用 `code` 参数传入Codemirror支持的选项即可(最简单的情况是直接传入` code={}` 或 `code=True`):
+    ```python
+    code = textarea('Code Edit', code={
+        'mode': "python",  # 编辑区代码语言
+        'theme': 'darcula',  # 编辑区darcula主题
+    }, value='import something\n# Write your python code')
+    ```
+        """, strip_indent=4)
+
+    code = 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("Your code:\n```python\n%s\n```" % code)
+
+    # 输入组
+    put_markdown(r"""### 输入组
+    `input_group()` 接受单项输入组成的列表作为参数,输入组中需要在每一项输入函数中提供 `name` 参数来用于在结果中标识不同输入项。输入组中同样支持设置校验函数,其接受整个表单数据作为参数。
+
+    ```python
+    def check_form(data):  # 检验函数校验通过时返回None,否则返回 (input name,错误消息)
+        if len(data['name']) > 6:
+            return ('name', '名字太长!')
+        if data['age'] <= 0:
+            return ('age', '年龄不能为负数!')
+
+    data = 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)
+    ```
+    """, strip_indent=4)
+
+    def check_form(data):  # 检验函数校验通过时返回None,否则返回 (input name,错误消息)
+        if len(data['name']) > 6:
+            return ('name', '名字太长!')
+        if data['age'] <= 0:
+            return ('age', '年龄不能为负数!')
+
+    data = 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_markdown("`data = %r`" % data)
+
+
+if __name__ == '__main__':
+    start_server(main, debug=True, port=8080)

+ 148 - 0
demos/output_usage.py

@@ -0,0 +1,148 @@
+"""
+输出演示
+^^^^^^^^^^^
+演示PyWebIO支持的各种输出形式
+
+:demo_host:`Demo地址 </?pywebio_api=output_usage>`  `源码 <https://github.com/wang0618/PyWebIO/blob/master/demos/output_usage.py>`_
+"""
+from pywebio import start_server
+from pywebio.output import *
+from pywebio.session import hold
+
+
+def main():
+    set_auto_scroll_bottom(False)
+    set_title("PyWebIO输出演示")
+
+    put_markdown("""# PyWebIO 输入演示
+    
+    在[这里](https://github.com/wang0618/PyWebIO/blob/master/demos/input_usage.py)可以获取本Demo的源码。
+    
+    PyWebIO的输出函数都定义在 `pywebio.output` 模块中,可以使用 `from pywebio.output import *` 引入。
+
+    ### 基本输出
+    PyWebIO提供了一些便捷函数来输出表格、链接等格式:
+    ```python
+    # 文本输出
+    put_text("Hello world!")
+
+    # 表格输出
+    put_table([
+        ['商品', '价格'],
+        ['苹果', '5.5'],
+        ['香蕉', '7'],
+    ])
+
+    # Markdown输出
+    put_markdown('~~删除线~~')
+
+    # 文件输出
+    put_file('hello_word.txt', b'hello word!')
+    ```
+    
+    PyWebIO提供的全部输出函数请参考PyWebIO文档
+    """, strip_indent=4)
+    # 文本输出
+    put_text("Hello world!")
+    # 表格输出
+    put_table([
+        ['商品', '价格'],
+        ['苹果', '5.5'],
+        ['香蕉', '7'],
+    ])
+    # Markdown输出
+    put_markdown('~~删除线~~')
+    # 文件输出
+    put_file('hello_word.txt', b'hello word!')
+
+    put_markdown(r"""### 输出事件回调
+    PyWebIO允许你输出一些控件,当控件被点击时执行提供的回调函数,就像编写GUI程序一样。
+    
+    下面是一个例子:
+    ```python
+    from functools import partial
+
+    def edit_row(choice, row):
+        put_markdown("> 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)
+
+    from functools import partial
+
+    def edit_row(choice, row):
+        put_markdown("> You click `%s` button ar row `%s`" % (choice, row), anchor='table-callback')
+
+    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))],
+    ])
+    set_anchor('table-callback')
+
+    put_markdown(r"""当然,PyWebIO还支持单独的按钮控件:
+    ```python
+    def btn_click(btn_val):
+        put_markdown("> You click `%s` button" % btn_val)
+
+    put_buttons(['A', 'B', 'C'], onclick=btn_click)
+    ```
+    """, strip_indent=4)
+
+    def btn_click(btn_val):
+        put_markdown("> You click `%s` button" % btn_val, anchor='button-callback')
+
+    put_buttons(['A', 'B', 'C'], onclick=btn_click)
+    set_anchor('button-callback')
+
+    put_markdown(r"""### 锚点
+    就像在控制台输出文本一样,PyWebIO默认在页面的末尾输出各种内容,你可以使用锚点来改变这一行为。
+
+    你可以调用 `set_anchor(name)` 对当前输出位置进行标记。
+    
+    你可以在任何输出函数中使用 `before` 参数将内容插入到指定的锚点之前,也可以使用 `after` 参数将内容插入到指定的锚点之后。
+    
+    在输出函数中使用 `anchor` 参数为当前的输出内容标记锚点,若锚点已经存在,则将锚点处的内容替换为当前内容。
+    
+    以下代码展示了在输出函数中使用锚点:
+    ```python
+    set_anchor('top')
+    put_text('A')
+    put_text('B', anchor='b')
+    put_text('C', after='top')
+    put_text('D', before='b')
+    ```
+    以上代码将输出:
+    
+        C
+        A
+        D
+        B
+
+    """, strip_indent=4)
+
+    put_markdown(r"""### 页面环境设置
+    #### 输出区外观
+    PyWebIO支持两种外观:输出区固定高度/可变高度。 可以通过调用 `set_output_fixed_height(True)` 来开启输出区固定高度。
+    
+    #### 设置页面标题
+    
+    调用 `set_title(title)` 可以设置页面标题。
+    
+    #### 自动滚动
+    
+    在不指定锚点进行输出时,PyWebIO默认在输出完毕后自动将页面滚动到页面最下方;在调用输入函数时,也会将页面滚动到表单处。 通过调用 `set_auto_scroll_bottom(False)` 来关闭自动滚动。
+
+    """, strip_indent=4)
+    hold()
+
+
+if __name__ == '__main__':
+    start_server(main, debug=True, port=8080)

+ 19 - 0
docs/conf.py

@@ -30,6 +30,8 @@ extensions = [
     'sphinx.ext.autodoc',
     # "sphinx.ext.intersphinx",
     "sphinx.ext.viewcode",
+    'sphinx_tabs.tabs',
+    'sphinx.ext.extlinks'
 ]
 
 primary_domain = "py"
@@ -57,7 +59,24 @@ exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
 #
 html_theme = "sphinx_rtd_theme"
 
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+html_static_path = ['static']
+
+
+def setup(app):
+    """Configure Sphinx"""
+    app.add_stylesheet('pywebio.css')
+
+
 # -- Extension configuration -------------------------------------------------
+from demos import config
+
+extlinks = {
+    'demo_host': (config.demo_host + '%s', 'demo_host'),
+    'charts_demo_host': (config.charts_demo_host + '%s', 'charts_demo_host')
+}
 
 from sphinx.builders.html import StandaloneHTMLBuilder
 

+ 12 - 0
docs/demos.rst

@@ -0,0 +1,12 @@
+Demos
+==========
+
+基本demo
+------------
+使用PyWebIO编写的示例应用
+
+.. automodule:: demos
+
+数据可视化demo
+-----------------
+PyWebIO支持使用第三方库进行数据可视化,详情见 :ref:`使用PyWebIO进行数据可视化 <visualization>`

+ 106 - 52
docs/guide.rst

@@ -270,86 +270,139 @@ Server mode 下,由于对多会话的支持,如果需要在新创建的线
 
 PyWebIO 目前支持与Flask和Tornado Web框架的集成。
 与Web框架集成需要完成两件事情:托管PyWebIO静态文件;暴露PyWebIO后端接口。
-这其中需要注意静态文件和后端接口的路径约定,以及静态文件与后端接口分开部署时因为跨域而需要的特别设置。
+这其中需要注意前端页面和后端接口的路径约定,以及前端静态文件与后端接口分开部署时因为跨域而需要的特别设置。
 
-与Tornado集成
-^^^^^^^^^^^^^^^^
+不同Web框架的集成方法如下:
 
-要将使用PyWebIO编写的任务函数集成进Tornado应用,需要在Tornado应用中引入两个 ``RequestHandler`` ,
-一个 ``RequestHandler`` 用来提供静态的前端文件,另一个 ``RequestHandler`` 用来和浏览器进行WebSocket通讯::
+.. tabs::
 
-    import tornado.ioloop
-    import tornado.web
-    from pywebio.platform.tornado import webio_handler
-    from pywebio import STATIC_PATH
+   .. tab:: Tornado
 
-    class MainHandler(tornado.web.RequestHandler):
-        def get(self):
-            self.write("Hello, world")
+        需要在Tornado应用中引入两个 ``RequestHandler`` ,
+        一个 ``RequestHandler`` 用来提供静态的前端文件,另一个 ``RequestHandler`` 用来和浏览器进行WebSocket通讯::
 
-    if __name__ == "__main__":
-        application = tornado.web.Application([
-            (r"/", MainHandler),
-            (r"/tool/io", webio_handler(task_func)),  # task_func 为使用PyWebIO编写的任务函数
-            (r"/tool/(.*)", tornado.web.StaticFileHandler,
-                  {"path": STATIC_PATH, 'default_filename': 'index.html'})
-        ])
-        application.listen(port=80, address='localhost')
-        tornado.ioloop.IOLoop.current().start()
+            import tornado.ioloop
+            import tornado.web
+            from pywebio.platform.tornado import webio_handler
+            from pywebio import STATIC_PATH
 
-以上代码调用 `webio_handler(task_func) <pywebio.platform.webio_handler>` 来获得PyWebIO和浏览器进行通讯的Tornado ``RequestHandler`` ,
-并将其绑定在 ``/tool/io`` 路径下;同时将PyWebIO的静态文件使用 ``tornado.web.StaticFileHandler`` 托管到 ``/tool/(.*)`` 路径下。
-启动Tornado服务后,访问 ``http://localhost/tool/`` 即可使用PyWebIO服务
+            class MainHandler(tornado.web.RequestHandler):
+                def get(self):
+                    self.write("Hello, world")
 
-.. note::
+            if __name__ == "__main__":
+                application = tornado.web.Application([
+                    (r"/", MainHandler),
+                    (r"/tool/io", webio_handler(task_func)),  # task_func 为使用PyWebIO编写的任务函数
+                    (r"/tool/(.*)", tornado.web.StaticFileHandler,
+                          {"path": STATIC_PATH, 'default_filename': 'index.html'})
+                ])
+                application.listen(port=80, address='localhost')
+                tornado.ioloop.IOLoop.current().start()
 
-   在Tornado中,PyWebIO使用WebSocket协议和浏览器进行通讯,所以,如果你的Tornado应用处在反向代理(比如Nginx)之后,
-   可能需要特别配置反向代理来支持WebSocket协议,:ref:`这里 <nginx_ws_config>` 有一个Nginx配置WebSocket的例子。
+        以上代码调用 `webio_handler(task_func) <pywebio.platform.tornado.webio_handler>` 来获得PyWebIO和浏览器进行通讯的Tornado ``RequestHandler`` ,
+        并将其绑定在 ``/tool/io`` 路径下;同时将PyWebIO的静态文件使用 ``tornado.web.StaticFileHandler`` 托管到 ``/tool/(.*)`` 路径下。
+        启动Tornado服务后,访问 ``http://localhost/tool/`` 即可使用PyWebIO服务
 
+        .. note::
 
-与Flask集成
-^^^^^^^^^^^^^^^^
+           在Tornado中,PyWebIO使用WebSocket协议和浏览器进行通讯,所以,如果你的Tornado应用处在反向代理(比如Nginx)之后,
+           可能需要特别配置反向代理来支持WebSocket协议,:ref:`这里 <nginx_ws_config>` 有一个Nginx配置WebSocket的例子。
 
-和集成到Tornado相似,在与Flask集成的集成中,你也需要添加两个PyWebIO相关的路由:一个用来提供静态的前端文件,另一个用来和浏览器进行Http通讯::
+   .. tab:: Flask
 
-    from pywebio.platform.flask import webio_view
-    from pywebio import STATIC_PATH
-    from flask import Flask, send_from_directory
+        需要添加两个PyWebIO相关的路由:一个用来提供静态的前端文件,另一个用来和浏览器进行Http通讯::
 
-    app = Flask(__name__)
-    app.route('/io', methods=['GET', 'POST', 'OPTIONS'])(webio_view(task_func))
+            from pywebio.platform.flask import webio_view
+            from pywebio import STATIC_PATH
+            from flask import Flask, send_from_directory
 
-    @app.route('/')
-    @app.route('/<path:static_file>')
-    def serve_static_file(static_file='index.html'):
-        return send_from_directory(STATIC_PATH, static_file)
+            app = Flask(__name__)
+
+            # task_func 为使用PyWebIO编写的任务函数
+            app.add_url_rule('/io', 'webio_view', webio_view(target=task_func),
+                             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)
+
+            app.run(host='localhost', port=80)
+
+   .. tab:: Django
+
+        在django的路由配置文件 ``urls.py`` 中加入PyWebIO相关的路由即可::
+
+            # urls.py
 
-    app.run(host='localhost', port=80)
+            from functools import partial
+            from django.urls import path
+            from django.views.static import serve
+            from pywebio import STATIC_PATH
+            from pywebio.platform.django import webio_view
+
+            # task_func 为使用PyWebIO编写的任务函数
+            webio_view_func = webio_view(target=task_func)
+
+            urlpatterns = [
+                path(r"io", webio_view_func),
+                path(r'', partial(serve, path='index.html'), {'document_root': STATIC_PATH}),
+                path(r'<path:path>', serve, {'document_root': STATIC_PATH}),
+            ]
+
+   .. tab:: aiohttp
+
+        添加两个PyWebIO相关的路由:一个用来提供静态的前端文件,另一个用来和浏览器进行WebSocket通讯::
+
+            from aiohttp import web
+            from pywebio.platform.aiohttp import static_routes, webio_handler
+            from pywebio import STATIC_PATH
+
+            app = web.Application()
+            # task_func 为使用PyWebIO编写的任务函数
+            app.add_routes([web.get('/io', webio_handler(task_func))])
+            app.add_routes(static_routes(STATIC_PATH))
+
+            web.run_app(app, host='localhost', port=8080)
 
 
 .. _integration_web_framework_note:
 
 注意事项
 ^^^^^^^^^^^
+**PyWebIO静态资源的托管**
+
+在开发阶段,使用后端框架提供的静态文件服务对于开发和调试都十分方便,上文的与Web框架集成的示例代码也都是使用了后端框架提供的静态文件服务。
+但出于性能考虑,托管静态文件最好的方式是使用 `反向代理 <https://en.wikipedia.org/wiki/Reverse_proxy>`_ (比如 `nginx <https://nginx.org/>`_ )
+或者 `CDN <https://en.wikipedia.org/wiki/Content_delivery_network>`_ 服务。
 
-PyWebIO默认通过当前页面的同级的 ``./io`` API与后端进行通讯,比如如果你将PyWebIO静态文件托管到 ``/A/B/C/(.*)`` 路径下,那么你需要将
-``webio_handler()`` 返回的 ``RequestHandler`` 绑定到 ``/A/B/C/io`` 处。如果你没有这样做的话,你需要在打开PyWebIO前端页面时,
-传入 ``pywebio_api`` Url参数来指定PyWebIO后端API地址,比如 ``/A/B/C/?pywebio_api=/D/pywebio`` 将PyWebIO后端API地址设置到了
-``/D/pywebio`` 处。 ``pywebio_api`` 参数可以使用相对地址、绝对地址甚至指定其他服务器。
+**前端页面和后端接口的路径约定**
+
+PyWebIO默认通过当前页面的同级的 ``./io`` API与后端进行通讯。
+
+例如你将PyWebIO静态文件托管到 ``/A/B/C/(.*)`` 路径下,那么你需要将PyWebIO API的路由绑定到 ``/A/B/C/io`` 处;
+你也可以在PyWebIO前端页面使用 ``pywebio_api`` Url参数来指定PyWebIO后端API地址,
+例如 ``/A/B/C/?pywebio_api=/D/pywebio`` 将PyWebIO后端API地址设置到了 ``/D/pywebio`` 处。
+
+``pywebio_api`` 参数可以使用相对地址、绝对地址甚至指定其他服务器。
 
 如果你不想自己托管静态文件,你可以使用PyWebIO的Github Page页面: ``https://wang0618.github.io/PyWebIO/pywebio/html/?pywebio_api=`` ,需要在页面上通过 ``pywebio_api`` 参数传入后端API地址,并且将 ``https://wang0618.github.io`` 加入 ``allowed_origins`` 列表中(见下文说明)。
 
 .. caution::
 
    需要注意 ``pywebio_api`` 参数的格式:
-   相对地址可以为 ``./xxx/xxx`` 或 ``xxx/xxx`` 的格式
-   绝对地址以 ``/`` 开头,比如 ``/aaa/bbb``
-   指定其他服务器需要使用完整格式: ``ws://example.com:8080/aaa/io`` ,或者省略协议字段: ``//example.com:8080/aaa/io`` 。
-   省略协议字段时,PyWebIO根据当前页面的协议确定要使用的协议: 若当前页面为http协议,则后端接口为ws协议;若当前页面为https协议,则后端接口为wss协议;
 
-   当后端API与当前页面不再同一host下时,需要在 `webio_handler() <pywebio.platform.webio_handler>` 或
-   `webio_view() <pywebio.platform.flask.webio_view>` 中使用 ``allowed_origins`` 或 ``check_origin``
-   参数来允许后端接收页面所在的host
+   * 相对地址可以为 ``./xxx/xxx`` 或 ``xxx/xxx`` 的相对地址格式。
+   * 绝对地址以 ``/`` 开头,比如 ``/aaa/bbb`` .
+   * 指定其他服务器需要使用完整格式: ``http://example.com:5000/aaa/io`` 、 ``ws://example.com:8080/bbb/ws_io`` ,或者省略协议字段: ``//example.com:8080/aaa/io`` 。省略协议字段时,PyWebIO根据当前页面的协议确定要使用的协议: 若当前页面为http协议,则后端接口为http/ws协议;若当前页面为https协议,则后端接口为https/wss协议。
+
+
+**跨域配置**
+
+当后端API与当前页面不再同一host下时,需要在 `webio_handler() <pywebio.platform.tornado.webio_handler>` 或
+`webio_view() <pywebio.platform.flask.webio_view>` 中使用 ``allowed_origins`` 或 ``check_origin``
+参数来使后端接受前端页面的请求。
 
 .. _coroutine_based_session:
 
@@ -432,7 +485,8 @@ PyWebIO的会话实现默认是基于线程的,用户每打开一个和服务
     from flask import Flask, send_from_directory
     from pywebio import STATIC_PATH
     from pywebio.output import *
-    from pywebio.platform.flask import webio_view, run_event_loop
+    from pywebio.platform.flask import webio_view
+    from pywebio.platform.httpbased import run_event_loop
     from pywebio.session import run_asyncio_coroutine
 
     async def hello_word():

+ 9 - 2
docs/index.rst

@@ -10,7 +10,7 @@ PyWebIO还可以方便地整合进现有的Web服务,让你不需要编写Html
 - 使用同步而不是基于回调的方式获取输入,无需在各个步骤之间保存状态,使用更方便
 - 代码侵入性小,对于旧脚本代码仅需修改输入输出逻辑
 - 支持多用户与并发请求
-- 支持整合到现有的Web服务,目前支持与Tornado和Flask的集成
+- 支持整合到现有的Web服务,目前支持与Flask、Django、Tornado、aiohttp框架集成
 - 同时支持基于线程的执行模型和基于协程的执行模型
 
 
@@ -71,7 +71,7 @@ Documentation
 这个文档同时也提供 `PDF 和 Epub 格式 <https://readthedocs.org/projects/pywebio/downloads/>`_.
 
 .. toctree::
-   :maxdepth: 3
+   :maxdepth: 2
    :caption: 使用手册
 
    guide
@@ -79,8 +79,15 @@ Documentation
    output
    session
    platform
+   libraries_support
+   demos
    misc
 
+.. toctree::
+   :maxdepth: 1
+
+   releases
+
 .. toctree::
    :maxdepth: 2
    :caption: 实现文档

+ 52 - 0
docs/libraries_support.rst

@@ -0,0 +1,52 @@
+第三方库生态
+==============
+
+.. _visualization:
+
+数据可视化
+-------------
+PyWebIO支持使用第三方库进行数据可视化
+
+pyecharts
+^^^^^^^^^^^^^^^^^^^^^^
+`pyecharts <https://github.com/pyecharts/pyecharts>`_ 是一个使用Python创建 `Echarts <https://github.com/ecomfe/echarts>`_ 可视化图表的库。
+
+在 PyWebIO 中使用 `put_html() <pywebio.output.put_html>` 可以输出 pyecharts 库创建的图表::
+
+    # chart 为 pyecharts 的图表实例
+    pywebio.output.put_html(chart.render_notebook())
+
+相应demo见 :charts_demo_host:`pyecharts demo </?pywebio_api=pyecharts>`
+
+.. only:: not latex
+
+    .. image:: https://cdn.jsdelivr.net/gh/wang0618/pywebio-chart-gallery@master/assets/pyecharts.gif
+
+plotly
+^^^^^^^^^^^^^^^^^^^^^^
+`plotly.py <https://github.com/plotly/plotly.py>`_ 是一个非常流行的Python数据可视化库,可以生成高质量的交互式图表。
+
+PyWebIO 支持输出使用 plotly 库创建的图表。使用方式为在PyWebIO会话中调用::
+
+    # fig 为 plotly 的图表实例
+    html = fig.to_html(include_plotlyjs="require", full_html=False)
+    pywebio.output.put_html(html)
+
+相应demo见 :charts_demo_host:`plotly demo </?pywebio_api=plotly>`
+
+.. image:: https://cdn.jsdelivr.net/gh/wang0618/pywebio-chart-gallery@master/assets/plotly.png
+
+cutecharts.py
+^^^^^^^^^^^^^^^^^^^^^^
+
+`cutecharts.py <https://github.com/cutecharts/cutecharts.py>`_ 是一个可以创建具有卡通风格的可视化图表的python库。
+底层使用了 `chart.xkcd <https://github.com/timqian/chart.xkcd>`_ Javascript库。
+
+在 PyWebIO 中使用 `put_html() <pywebio.output.put_html>` 可以输出 cutecharts.py 库创建的图表::
+
+    # chart 为 cutecharts 的图表实例
+    pywebio.output.put_html(chart.render_notebook())
+
+相应demo见 :charts_demo_host:`cutecharts demo </?pywebio_api=cutecharts>`
+
+.. image:: https://cdn.jsdelivr.net/gh/wang0618/pywebio-chart-gallery@master/assets/cutecharts.png

+ 7 - 0
docs/releases.rst

@@ -0,0 +1,7 @@
+Release notes
+=============
+
+.. toctree::
+   :maxdepth: 2
+
+   releases/v0.2.0

+ 49 - 0
docs/releases/v0.2.0.rst

@@ -0,0 +1,49 @@
+What's new in PyWebIO 0.2
+==========================
+
+2020 4/30
+----------
+
+Highlights
+^^^^^^^^^^
+
+* 支持与Django、aiohttp Web框架整合
+* 支持使用 plotly、pyecharts 等第三方库进行数据可视化
+* 与Web框架整合时支持同时使用基于线程和协程的会话实现
+* 添加 `defer_call() <pywebio.session.defer_call>` 、 `hold() <pywebio.session.hold>` 会话控制函数
+* 添加 `put_image() <pywebio.output.put_image>` 输出图像、 `remove(anchor)  <pywebio.output.remove>` 移除内容
+* 加入动画提升UI体验
+* 添加测试用例,构建CI工作流
+
+Detailed changes by module
+^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+UI
+~~~~~~~~~~~~~~
+
+* 添加元素显示动画
+* 页面底部添加footer
+
+`pywebio.input`
+~~~~~~~~~~~~~~~~
+
+* `input_group() <pywebio.input.input_group>` 添加 ``cancelable`` 参数来允许用户取消输入
+* `actions() <pywebio.input.actions>` 函数 ``button`` 参数支持 ``reset`` 和 ``cancel`` 按钮类型
+
+`pywebio.output`
+~~~~~~~~~~~~~~~~
+
+* 输出函数使用 ``anchor`` 参数指定输出锚点时,若锚点已经存在,则将锚点处的内容替换为当前内容。
+* `clear_range() <pywebio.output.clear_range>` 添加添加锚点存在检查
+* `scroll_to(anchor, position) <pywebio.output.scroll_to>` 添加 ``position`` 参数精细化控制滚动位置
+
+`pywebio.platform`
+~~~~~~~~~~~~~~~~~~~
+
+* `start_server` 和 `webio_view` 、 `webio_handle` 添加跨域支持
+
+`pywebio.session`
+~~~~~~~~~~~~~~~~~~~
+
+* Session 关闭时,清理更彻底:任何还在进行的PyWebIO调用都会抛出 ``SessionClosedException`` 异常
+* fix: Session 对象构造函数无法识别 ``functools.partial`` 处理的任务函数

+ 18 - 0
docs/static/pywebio.css

@@ -0,0 +1,18 @@
+/* Tabs */
+
+.ui.menu {
+    font-family: Helvetica;
+    min-height: 0;
+}
+
+.ui.tabular.menu .item {
+    padding: 9px 1em;
+}
+
+.ui.menu .item {
+    padding: 0;
+}
+
+.sphinx-tabs {
+    margin-bottom: 1em;
+}

+ 2 - 2
pywebio/__version__.py

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

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

@@ -246,7 +246,9 @@
             this.container_elem.find(`#${msg.spec.clear_after}~*`).remove();
         if (msg.spec.scroll_to !== undefined) {
             var target = $(`#${msg.spec.scroll_to}`);
-            if (OutputFixedHeight) {
+            if (!target.length) {
+                console.error(`Anchor ${msg.spec.scroll_to} not found`);
+            } else if (OutputFixedHeight) {
                 box_scroll_to(target, this.container_parent, msg.spec.position);
             } else {
                 body_scroll_to(target, msg.spec.position);

+ 5 - 1
pywebio/output.py

@@ -45,6 +45,10 @@ try:
 except ImportError:
     PILImage = type('MockPILImage', (), dict(__init__=None))
 
+__all__ = ['TOP', 'MIDDLE', 'BOTTOM', 'set_title', 'set_output_fixed_height', 'set_auto_scroll_bottom', 'set_anchor',
+           'clear_before', 'clear_after', 'clear_range', 'remove', 'scroll_to', 'put_text', 'put_html',
+           'put_code', 'put_markdown', 'put_table', 'table_cell_buttons', 'put_buttons', 'put_image', 'put_file']
+
 TOP = 'top'
 MIDDLE = 'middle'
 BOTTOM = 'bottom'
@@ -164,7 +168,7 @@ def put_html(html, anchor=None, before=None, after=None):
     """
     输出Html内容。
 
-    与支持通过Html输出内容到 `Jupyter Notebook <https://nbviewer.jupyter.org/github/ipython/ipython/blob/master/examples/IPython%20Kernel/Rich%20Output.ipynb#HTML>` 的库兼容。
+    与支持通过Html输出内容到 `Jupyter Notebook <https://nbviewer.jupyter.org/github/ipython/ipython/blob/master/examples/IPython%20Kernel/Rich%20Output.ipynb#HTML>`_ 的库兼容。
 
     :param html: html字符串或 实现了 `IPython.display.HTML` 接口的类的实例
     :param str anchor, before, after: 与 `put_text` 函数的同名参数含义一致

+ 14 - 10
pywebio/platform/__init__.py

@@ -3,25 +3,29 @@ r"""
 
 Tornado相关
 --------------
-
 .. autofunction:: start_server
 .. autofunction:: pywebio.platform.tornado.webio_handler
 
 Flask相关
 --------------
-
 .. autofunction:: pywebio.platform.flask.webio_view
-.. autofunction:: pywebio.platform.flask.run_event_loop
 .. autofunction:: pywebio.platform.flask.start_server
 
+Django相关
+--------------
+.. autofunction:: pywebio.platform.django.webio_view
+.. autofunction:: pywebio.platform.django.start_server
 
-"""
+aiohttp相关
+--------------
+.. autofunction:: pywebio.platform.aiohttp.webio_handler
+.. autofunction:: pywebio.platform.aiohttp.static_routes
+.. autofunction:: pywebio.platform.aiohttp.start_server
 
-from . import tornado
-from .tornado import start_server
+其他
+--------------
+.. autofunction:: pywebio.platform.httpbased.run_event_loop
 
-try:
-    from . import flask
-except ImportError:
-    pass
+"""
 
+from .tornado import start_server

+ 188 - 0
pywebio/platform/aiohttp.py

@@ -0,0 +1,188 @@
+import asyncio
+import fnmatch
+import logging
+from functools import partial
+from urllib.parse import urlparse
+from os import path, listdir
+from aiohttp import web
+
+from .tornado import open_webbrowser_on_server_started
+from ..session import CoroutineBasedSession, ThreadBasedSession, register_session_implement_for_target, AbstractSession
+from ..utils import get_free_port, STATIC_PATH
+
+logger = logging.getLogger(__name__)
+
+
+def _check_origin(origin, allowed_origins, host):
+    if _is_same_site(origin, host):
+        return True
+
+    return any(
+        fnmatch.fnmatch(origin, patten)
+        for patten in allowed_origins
+    )
+
+
+def _is_same_site(origin, host):
+    """判断 origin 和 host 是否一致。origin 和 host 都为http协议请求头"""
+    parsed_origin = urlparse(origin)
+    origin = parsed_origin.netloc
+    origin = origin.lower()
+
+    # Check to see that origin matches host directly, including ports
+    return origin == host
+
+
+def _webio_handler(target, session_cls, websocket_settings, 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类
+    """
+    ioloop = asyncio.get_event_loop()
+
+    async def wshandle(request: web.Request):
+        origin = request.headers.get('origin')
+        if origin and not check_origin_func(origin=origin, host=request.host):
+            return web.Response(status=403, text="Cross origin websockets not allowed")
+
+        ws = web.WebSocketResponse(**websocket_settings)
+        await ws.prepare(request)
+
+        close_from_session_tag = False  # 是否由session主动关闭连接
+
+        def send_msg_to_client(session: AbstractSession):
+            for msg in session.get_task_commands():
+                ioloop.create_task(ws.send_json(msg))
+
+        def close_from_session():
+            nonlocal close_from_session_tag
+            close_from_session_tag = True
+            ioloop.create_task(ws.close())
+            logger.debug("WebSocket closed from session")
+
+        if session_cls is CoroutineBasedSession:
+            session = CoroutineBasedSession(target, on_task_command=send_msg_to_client,
+                                            on_session_close=close_from_session)
+        elif session_cls is ThreadBasedSession:
+            session = ThreadBasedSession(target, on_task_command=send_msg_to_client,
+                                         on_session_close=close_from_session, loop=ioloop)
+        else:
+            raise RuntimeError("Don't support session type:%s" % session_cls)
+
+        async for msg in ws:
+            if msg.type == web.WSMsgType.text:
+                data = msg.json()
+                if data is not None:
+                    session.send_client_event(data)
+            elif msg.type == web.WSMsgType.binary:
+                pass
+            elif msg.type == web.WSMsgType.close:
+                if not close_from_session_tag:
+                    session.close()
+                    logger.debug("WebSocket closed from client")
+
+        return ws
+
+    return wshandle
+
+
+def webio_handler(target, allowed_origins=None, check_origin=None, websocket_settings=None):
+    """获取在aiohttp中运行PyWebIO任务函数的 `Request Handle <https://docs.aiohttp.org/en/stable/web_quickstart.html#aiohttp-web-handler>`_ 协程。
+    Request Handle基于WebSocket协议与浏览器进行通讯。
+
+    :param target: 任务函数。任务函数为协程函数时,使用 :ref:`基于协程的会话实现 <coroutine_based_session>` ;任务函数为普通函数时,使用基于线程的会话实现。
+    :param list allowed_origins: 除当前域名外,服务器还允许的请求的来源列表。
+        来源包含协议和域名和端口部分,允许使用 Unix shell 风格的匹配模式:
+
+        - ``*`` 为通配符
+        - ``?`` 匹配单个字符
+        - ``[seq]`` 匹配seq内的字符
+        - ``[!seq]`` 匹配不在seq内的字符
+
+        比如 ``https://*.example.com`` 、 ``*://*.example.com`` 、
+    :param callable check_origin: 请求来源检查函数。接收请求来源(包含协议和域名和端口部分)字符串,
+        返回 ``True/False`` 。若设置了 ``check_origin`` , ``allowed_origins`` 参数将被忽略
+    :param dict websocket_settings: 创建 aiohttp WebSocketResponse 时使用的参数。见 https://docs.aiohttp.org/en/stable/web_reference.html#websocketresponse
+    :return: aiohttp Request Handler
+    """
+    session_cls = register_session_implement_for_target(target)
+
+    websocket_settings = websocket_settings or {}
+
+    if check_origin is None:
+        check_origin_func = partial(_check_origin, allowed_origins=allowed_origins or [])
+    else:
+        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,
+                          websocket_settings=websocket_settings)
+
+
+def static_routes(static_path):
+    """获取用于提供PyWebIO静态文件的aiohttp路由"""
+
+    async def index(request):
+        return web.FileResponse(path.join(STATIC_PATH, 'index.html'))
+
+    files = [path.join(static_path, d) for d in listdir(static_path)]
+    dirs = filter(path.isdir, files)
+    routes = [web.static('/' + path.basename(d), d) for d in dirs]
+    routes.append(web.get('/', index))
+    return routes
+
+
+def start_server(target, port=0, host='', debug=False,
+                 allowed_origins=None, check_origin=None,
+                 auto_open_webbrowser=False,
+                 websocket_settings=None,
+                 **aiohttp_settings):
+    """启动一个 aiohttp server 将 ``target`` 任务函数作为Web服务提供。
+
+    :param target: 任务函数。任务函数为协程函数时,使用 :ref:`基于协程的会话实现 <coroutine_based_session>` ;任务函数为普通函数时,使用基于线程的会话实现。
+    :param list allowed_origins: 除当前域名外,服务器还允许的请求的来源列表。
+    :param int port: server bind port. set ``0`` to find a free port number to use
+    :param str host: server bind host. ``host`` may be either an IP address or hostname.  If it's a hostname,
+        the server will listen on all IP addresses associated with the name.
+        set empty string or to listen on all available interfaces.
+    :param bool debug: asyncio Debug Mode
+    :param list allowed_origins: 除当前域名外,服务器还允许的请求的来源列表。
+        来源包含协议和域名和端口部分,允许使用 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 (if the operating system allows it) .
+    :param dict websocket_settings: 创建 aiohttp WebSocketResponse 时使用的参数。见 https://docs.aiohttp.org/en/stable/web_reference.html#websocketresponse
+    :param aiohttp_settings: 需要传给 aiohttp Application 的参数。可用参数见 https://docs.aiohttp.org/en/stable/web_reference.html#application
+    """
+    kwargs = locals()
+
+    if not host:
+        host = '0.0.0.0'
+
+    if port == 0:
+        port = get_free_port()
+
+    handler = webio_handler(target, allowed_origins=allowed_origins, check_origin=check_origin,
+                            websocket_settings=websocket_settings)
+
+    app = web.Application(**aiohttp_settings)
+    app.add_routes([web.get('/io', handler)])
+    app.add_routes(static_routes(STATIC_PATH))
+
+    if auto_open_webbrowser:
+        asyncio.get_event_loop().create_task(open_webbrowser_on_server_started('localhost', port))
+
+    if debug:
+        logging.getLogger("asyncio").setLevel(logging.DEBUG)
+
+    print('Listen on %s:%s' % (host, port))
+    web.run_app(app, host=host, port=port)

+ 200 - 0
pywebio/platform/django.py

@@ -0,0 +1,200 @@
+import json
+import logging
+import threading
+from functools import partial
+
+from django.http import HttpResponse, HttpRequest
+
+from .httpbased import HttpContext, HttpHandler, run_event_loop
+from ..session import register_session_implement_for_target
+from ..utils import STATIC_PATH, iscoroutinefunction, isgeneratorfunction, get_free_port
+
+logger = logging.getLogger(__name__)
+
+
+class DjangoHttpContext(HttpContext):
+    def __init__(self, request: HttpRequest):
+        self.request = request
+        self.response = HttpResponse()
+
+    def request_method(self):
+        """返回当前请求的方法,大写"""
+        return self.request.method
+
+    def request_headers(self):
+        """返回当前请求的header字典"""
+        return self.request.headers
+
+    def request_url_parameter(self, name, default=None):
+        """返回当前请求的URL参数"""
+        return self.request.GET.get(name, default=default)
+
+    def request_json(self):
+        """返回当前请求的json反序列化后的内容,若请求数据不为json格式,返回None"""
+        try:
+            return json.loads(self.request.body.decode('utf8'))
+        except Exception:
+            return None
+
+    def set_header(self, name, value):
+        """为当前响应设置header"""
+        self.response[name] = value
+
+    def set_status(self, status: int):
+        """为当前响应设置http status"""
+        self.response.status_code = status
+
+    def set_content(self, content, json_type=False):
+        """设置相应的内容
+
+        :param content:
+        :param bool json_type: content是否要序列化成json格式,并将 content-type 设置为application/json
+        """
+        if json_type:
+            self.set_header('content-type', 'application/json')
+            self.response.content = json.dumps(content)
+        else:
+            self.response.content = content
+
+    def get_response(self):
+        """获取当前的响应对象,用于在私图函数中返回"""
+        return self.response
+
+
+def webio_view(target,
+               session_expire_seconds=None,
+               session_cleanup_interval=None,
+               allowed_origins=None, check_origin=None):
+    """获取在django中运行PyWebIO任务的视图函数。
+    基于http请求与前端进行通讯
+
+    :param target: 任务函数。任务函数为协程函数时,使用 :ref:`基于协程的会话实现 <coroutine_based_session>` ;任务函数为普通函数时,使用基于线程的会话实现。
+    :param int session_expire_seconds: 会话不活跃过期时间。
+    :param int session_cleanup_interval: 会话清理间隔。
+    :param list allowed_origins: 除当前域名外,服务器还允许的请求的来源列表。
+        来源包含协议和域名和端口部分,允许使用 Unix shell 风格的匹配模式:
+
+        - ``*`` 为通配符
+        - ``?`` 匹配单个字符
+        - ``[seq]`` 匹配seq内的字符
+        - ``[!seq]`` 匹配不在seq内的字符
+
+        比如 ``https://*.example.com`` 、 ``*://*.example.com``
+    :param callable check_origin: 请求来源检查函数。接收请求来源(包含协议和域名和端口部分)字符串,
+        返回 ``True/False`` 。若设置了 ``check_origin`` , ``allowed_origins`` 参数将被忽略
+    :return: Django视图函数
+    """
+    session_cls = register_session_implement_for_target(target)
+    handler = HttpHandler(target=target, session_cls=session_cls,
+                          session_expire_seconds=session_expire_seconds,
+                          session_cleanup_interval=session_cleanup_interval,
+                          allowed_origins=allowed_origins, check_origin=check_origin)
+
+    from django.views.decorators.csrf import csrf_exempt
+    @csrf_exempt
+    def view_func(request):
+        context = DjangoHttpContext(request)
+        return handler.handle_request(context)
+
+    view_func.__name__ = 'webio_view'
+    return view_func
+
+
+urlpatterns = []
+
+
+def start_server(target, port=8080, host='localhost',
+                 allowed_origins=None, check_origin=None,
+                 disable_asyncio=False,
+                 session_cleanup_interval=None,
+                 session_expire_seconds=None,
+                 debug=False, **django_options):
+    """启动一个 Django server 将 ``target`` 任务函数作为Web服务提供。
+
+    :param target: 任务函数。任务函数为协程函数时,使用 :ref:`基于协程的会话实现 <coroutine_based_session>` ;任务函数为普通函数时,使用基于线程的会话实现。
+    :param int port: server bind port. set ``0`` to find a free port number to use
+    :param str host: server bind host. ``host`` may be either an IP address or hostname.  If it's a hostname,
+    :param list allowed_origins: 除当前域名外,服务器还允许的请求的来源列表。
+        来源包含协议和域名和端口部分,允许使用 Unix shell 风格的匹配模式:
+
+        - ``*`` 为通配符
+        - ``?`` 匹配单个字符
+        - ``[seq]`` 匹配seq内的字符
+        - ``[!seq]`` 匹配不在seq内的字符
+
+        比如 ``https://*.example.com`` 、 ``*://*.example.com``
+    :param callable check_origin: 请求来源检查函数。接收请求来源(包含协议和域名和端口部分)字符串,
+        返回 ``True/False`` 。若设置了 ``check_origin`` , ``allowed_origins`` 参数将被忽略
+    :param bool disable_asyncio: 禁用 asyncio 函数。仅在 ``target`` 为协程函数时有效。
+
+       .. note::  实现说明:
+           当使用Django backend时,若要在PyWebIO的会话中使用 ``asyncio`` 标准库里的协程函数,PyWebIO需要单独开启一个线程来运行 ``asyncio`` 事件循环,
+           若程序中没有使用到 ``asyncio`` 中的异步函数,可以开启此选项来避免不必要的资源浪费
+
+    :param int session_expire_seconds: 会话过期时间。若 session_expire_seconds 秒内没有收到客户端的请求,则认为会话过期。
+    :param int session_cleanup_interval: 会话清理间隔。
+    :param bool debug: 开启 Django debug mode 和一般访问日志的记录
+    :param django_options: django应用的其他设置,见 https://docs.djangoproject.com/en/3.0/ref/settings/ .
+        其中 ``DEBUG`` 、 ``ALLOWED_HOSTS`` 、 ``ROOT_URLCONF`` 、 ``SECRET_KEY`` 被PyWebIO设置,不可以手动指定
+    """
+    global urlpatterns
+
+    from django.conf import settings
+    from django.core.wsgi import get_wsgi_application
+    from django.urls import path
+    from django.utils.crypto import get_random_string
+    from django.views.static import serve
+    from django.core.management import call_command
+
+    if port == 0:
+        port = get_free_port()
+
+    django_options.update(dict(
+        DEBUG=debug,
+        ALLOWED_HOSTS=["*"],  # Disable host header validation
+        ROOT_URLCONF=__name__,  # Make this module the urlconf
+        SECRET_KEY=get_random_string(10),  # We aren't using any security features but Django requires this setting
+    ))
+    django_options.setdefault('LOGGING', {
+        'version': 1,
+        'disable_existing_loggers': False,
+        'formatters': {
+            'simple': {
+                'format': '[%(asctime)s] %(message)s'
+            },
+        },
+        'handlers': {
+            'console': {
+                'class': 'logging.StreamHandler',
+                'formatter': 'simple'
+            },
+        },
+        'loggers': {
+            'django.server': {
+                'level': 'INFO' if debug else 'WARN',
+                'handlers': ['console'],
+            },
+        },
+    })
+    settings.configure(**django_options)
+
+    webio_view_func = webio_view(
+        target,
+        session_expire_seconds=session_expire_seconds,
+        session_cleanup_interval=session_cleanup_interval,
+        allowed_origins=allowed_origins,
+        check_origin=check_origin
+    )
+
+    urlpatterns = [
+        path(r"io", webio_view_func),
+        path(r'', partial(serve, path='index.html'), {'document_root': STATIC_PATH}),
+        path(r'<path:path>', serve, {'document_root': STATIC_PATH}),
+    ]
+
+    get_wsgi_application()
+
+    if not disable_asyncio and (iscoroutinefunction(target) or isgeneratorfunction(target)):
+        threading.Thread(target=run_event_loop, daemon=True).start()
+
+    call_command('runserver', '%s:%d' % (host, port))

+ 60 - 172
pywebio/platform/flask.py

@@ -2,167 +2,75 @@
 Flask backend
 
 .. note::
-    在 AsyncBasedSession 会话中,若在协程任务函数内调用 asyncio 中的协程函数,需要使用 asyncio_coroutine
-
-
-.. attention::
-    PyWebIO 的会话状态保存在进程内,所以不支持多进程部署的Flask。
-        比如使用 ``uWSGI`` 部署Flask,并使用 ``--processes n`` 选项设置了多进程;
-        或者使用 ``nginx`` 等反向代理将流量负载到多个 Flask 副本上。
-
-    A note on run Flask with uWSGI:
-
-    If you start uWSGI without threads, the Python GIL will not be enabled,
-    so threads generated by your application will never run. `uWSGI doc <https://uwsgi-docs.readthedocs.io/en/latest/WSGIquickstart.html#a-note-on-python-threads>`_
-    在Flask backend中,PyWebIO使用单独一个线程来运行事件循环。如果程序中没有使用到asyncio中的协程函数,
-    可以在 start_flask_server 参数中设置 ``disable_asyncio=False`` 来关闭对asyncio协程函数的支持。
-    如果您需要使用asyncio协程函数,那么需要在在uWSGI中使用 ``--enable-thread`` 选项开启线程支持。
-
+    在 CoroutineBasedSession 会话中,若在协程任务函数内调用 asyncio 中的协程函数,需要使用 asyncio_coroutine
 """
-import asyncio
-import fnmatch
+import json
 import logging
 import threading
-import time
-from functools import partial
-from typing import Dict
 
-from flask import Flask, request, jsonify, send_from_directory, Response
+from flask import Flask, request, send_from_directory, Response
 
-from ..session import CoroutineBasedSession, AbstractSession, register_session_implement_for_target
+from .httpbased import HttpContext, HttpHandler, run_event_loop
+from ..session import 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。按照最后活跃时间递增排列
-
-DEFAULT_SESSION_EXPIRE_SECONDS = 60  # 超过60s会话不活跃则视为会话过期
-SESSIONS_CLEANUP_INTERVAL = 20  # 清理过期会话间隔(秒)
-WAIT_MS_ON_POST = 100  # 在处理完POST请求时,等待WAIT_MS_ON_POST毫秒再读取返回数据。Task的command可以立即返回
-
-_event_loop = None
-
 
-def _make_response(webio_session: AbstractSession):
-    return jsonify(webio_session.get_task_commands())
+class FlaskHttpContext(HttpContext):
+    def __init__(self):
+        self.response = Response()
+        self.request_data = request.get_data()
 
+    def request_method(self):
+        """返回当前请求的方法,大写"""
+        return request.method
 
-def _remove_expired_sessions(session_expire_seconds):
-    logger.debug("removing expired sessions")
-    """清除当前会话列表中的过期会话"""
-    while _webio_expire:
-        sid, active_ts = _webio_expire.popitem(last=False)
+    def request_headers(self):
+        """返回当前请求的header字典"""
+        return request.headers
 
-        if time.time() - active_ts < session_expire_seconds:
-            # 当前session未过期
-            _webio_expire[sid] = active_ts
-            _webio_expire.move_to_end(sid, last=False)
-            break
+    def request_url_parameter(self, name, default=None):
+        """返回当前请求的URL参数"""
+        return request.args.get(name, default=default)
 
-        # 清理session
-        logger.debug("session %s expired" % sid)
-        session = _webio_sessions.get(sid)
-        if session:
-            session.close()
-            del _webio_sessions[sid]
+    def request_json(self):
+        """返回当前请求的json反序列化后的内容,若请求数据不为json格式,返回None"""
+        try:
+            return json.loads(self.request_data)
+        except Exception:
+            return None
 
+    def set_header(self, name, value):
+        """为当前响应设置header"""
+        self.response.headers[name] = value
 
-_last_check_session_expire_ts = 0  # 上次检查session有效期的时间戳
+    def set_status(self, status: int):
+        """为当前响应设置http status"""
+        self.response.status_code = status
 
+    def set_content(self, content, json_type=False):
+        """设置相应的内容
 
-def _remove_webio_session(sid):
-    _webio_sessions.pop(sid, None)
-    _webio_expire.pop(sid, None)
-
-
-def cors_headers(origin, check_origin, headers=None):
-    if headers is None:
-        headers = {}
-
-    if check_origin(origin):
-        headers['Access-Control-Allow-Origin'] = origin
-        headers['Access-Control-Allow-Methods'] = 'GET, POST'
-        headers['Access-Control-Allow-Headers'] = 'content-type, webio-session-id'
-        headers['Access-Control-Expose-Headers'] = 'webio-session-id'
-        headers['Access-Control-Max-Age'] = 1440 * 60
-
-    return headers
-
-
-def _webio_view(target, session_cls, session_expire_seconds, session_cleanup_interval, check_origin):
-    """
-    :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
-    if _event_loop:
-        asyncio.set_event_loop(_event_loop)
-
-    if request.method == 'OPTIONS':  # preflight request for CORS
-        headers = cors_headers(request.headers.get('Origin', ''), check_origin)
-        return Response('', headers=headers, status=204)
+        :param content:
+        :param bool json_type: content是否要序列化成json格式,并将 content-type 设置为application/json
+        """
+        if json_type:
+            self.set_header('content-type', 'application/json')
+            self.response.data = json.dumps(content)
+        else:
+            self.response.data = content
 
-    headers = {}
-
-    if request.headers.get('Origin'):  # set headers for CORS request
-        headers = cors_headers(request.headers.get('Origin'), check_origin, headers=headers)
-
-    if request.args.get('test'):  # 测试接口,当会话使用给予http的backend时,返回 ok
-        return Response('ok', headers=headers)
-
-    webio_session_id = None
-
-    # webio-session-id 的请求头为空时,创建新 Session
-    if 'webio-session-id' not in request.headers or not request.headers['webio-session-id']:  # start new WebIOSession
-        webio_session_id = random_str(24)
-        headers['webio-session-id'] = webio_session_id
-        webio_session = session_cls(target)
-        _webio_sessions[webio_session_id] = webio_session
-    elif request.headers['webio-session-id'] not in _webio_sessions:  # WebIOSession deleted
-        return jsonify([dict(command='close_session')])
-    else:
-        webio_session_id = request.headers['webio-session-id']
-        webio_session = _webio_sessions[webio_session_id]
-
-    if request.method == 'POST':  # client push event
-        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 > session_cleanup_interval:
-        _last_check_session_expire_ts = time.time()
-        _remove_expired_sessions(session_expire_seconds)
-
-    response = _make_response(webio_session)
-
-    if webio_session.closed():
-        _remove_webio_session(webio_session_id)
-
-    # set header to response
-    for k, v in headers.items():
-        response.headers[k] = v
-
-    return response
+    def get_response(self):
+        """获取当前的响应对象,用于在私图函数中返回"""
+        return self.response
 
 
 def webio_view(target,
-               session_expire_seconds=DEFAULT_SESSION_EXPIRE_SECONDS,
-               session_cleanup_interval=SESSIONS_CLEANUP_INTERVAL,
+               session_expire_seconds=None,
+               session_cleanup_interval=None,
                allowed_origins=None, check_origin=None):
-    """获取用于与Flask进行整合的view函数
+    """获取在Flask中运行PyWebIO任务的视图函数。基于http请求与前端进行通讯
 
     :param target: 任务函数。任务函数为协程函数时,使用 :ref:`基于协程的会话实现 <coroutine_based_session>` ;任务函数为普通函数时,使用基于线程的会话实现。
     :param int session_expire_seconds: 会话不活跃过期时间。
@@ -180,51 +88,31 @@ def webio_view(target,
         返回 ``True/False`` 。若设置了 ``check_origin`` , ``allowed_origins`` 参数将被忽略
     :return: Flask视图函数
     """
-
     session_cls = register_session_implement_for_target(target)
+    handler = HttpHandler(target=target, session_cls=session_cls,
+                          session_expire_seconds=session_expire_seconds,
+                          session_cleanup_interval=session_cleanup_interval,
+                          allowed_origins=allowed_origins, check_origin=check_origin)
 
-    if check_origin is None:
-        check_origin = lambda origin: any(
-            fnmatch.fnmatch(origin, patten)
-            for patten in allowed_origins or []
-        )
+    def view_func():
+        context = FlaskHttpContext()
+        return handler.handle_request(context)
 
-    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
 
 
-def run_event_loop(debug=False):
-    """运行事件循环
-
-    基于协程的会话在启动Flask服务器之前需要启动一个单独的线程来运行事件循环。
-
-    :param debug: Set the debug mode of the event loop.
-       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)
-    _event_loop.run_forever()
-
-
 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,
+                 session_cleanup_interval=None,
+                 session_expire_seconds=None,
                  debug=False, **flask_options):
-    """启动一个 Flask server 来运行PyWebIO的 ``target`` 服务
+    """启动一个 Flask server 将 ``target`` 任务函数作为Web服务提供。
 
-    :param target: task function. It's a coroutine function is use CoroutineBasedSession or
-        a simple function is use ThreadBasedSession.
-    :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 target: 任务函数。任务函数为协程函数时,使用 :ref:`基于协程的会话实现 <coroutine_based_session>` ;任务函数为普通函数时,使用基于线程的会话实现。
+    :param int port: server bind port. set ``0`` to find a free port number to use
+    :param str host: server bind host. ``host`` may be either an IP address or hostname.  If it's a hostname,
     :param list allowed_origins: 除当前域名外,服务器还允许的请求的来源列表。
         来源包含协议和域名和端口部分,允许使用 Unix shell 风格的匹配模式:
 
@@ -239,7 +127,7 @@ def start_server(target, port=8080, host='localhost',
     :param bool disable_asyncio: 禁用 asyncio 函数。仅在 ``target`` 为协程函数时有效。
 
        .. note::  实现说明:
-           当使用Flask backend时,若要在PyWebIO的会话中使用 ``asyncio`` 标准库里的协程函数,则需要在单独开启一个线程来运行 ``asyncio`` 事件循环,
+           当使用Flask backend时,若要在PyWebIO的会话中使用 ``asyncio`` 标准库里的协程函数,PyWebIO需要单独开启一个线程来运行 ``asyncio`` 事件循环,
            若程序中没有使用到 ``asyncio`` 中的异步函数,可以开启此选项来避免不必要的资源浪费
 
     :param int session_expire_seconds: 会话过期时间。若 session_expire_seconds 秒内没有收到客户端的请求,则认为会话过期。

+ 227 - 0
pywebio/platform/httpbased.py

@@ -0,0 +1,227 @@
+"""
+本模块提供基于Http轮训的后端通用类和函数
+
+.. attention::
+    PyWebIO 的会话状态保存在进程内,所以不支持多进程部署的后端服务
+        比如使用 ``uWSGI`` 部署后端服务,并使用 ``--processes n`` 选项设置了多进程;
+        或者使用 ``nginx`` 等反向代理将流量负载到多个后端副本上。
+
+    A note on run backend server with uWSGI:
+
+    If you start uWSGI without threads, the Python GIL will not be enabled,
+    so threads generated by your application will never run.
+    `uWSGI doc <https://uwsgi-docs.readthedocs.io/en/latest/WSGIquickstart.html#a-note-on-python-threads>`_
+
+"""
+import asyncio
+import fnmatch
+import logging
+import threading
+from typing import Dict
+
+import time
+
+from ..session import CoroutineBasedSession, AbstractSession, register_session_implement_for_target
+from ..utils import random_str, LRUDict
+
+
+class HttpContext:
+    """一次Http请求的上下文, 不同的后端框架需要根据框架提供的方法实现本类的方法"""
+
+    def request_method(self):
+        """返回当前请求的方法,大写"""
+        pass
+
+    def request_headers(self):
+        """返回当前请求的header字典"""
+        pass
+
+    def request_url_parameter(self, name, default=None):
+        """返回当前请求的URL参数"""
+        pass
+
+    def request_json(self):
+        """返回当前请求的json反序列化后的内容,若请求数据不为json格式,返回None"""
+        pass
+
+    def set_header(self, name, value):
+        """为当前响应设置header"""
+        pass
+
+    def set_status(self, status):
+        """为当前响应设置http status"""
+        pass
+
+    def set_content(self, content, json_type=False):
+        """设置响应的内容。方法应该仅被调用一次
+
+        :param content:
+        :param bool json_type: content是否要序列化成json格式,并将 content-type 设置为application/json
+        """
+        pass
+
+    def get_response(self):
+        """获取当前的响应对象,用于在私图函数中返回"""
+
+
+logger = logging.getLogger(__name__)
+_event_loop = None
+
+
+# todo: use lock to avoid thread race condition
+class HttpHandler:
+    # type: Dict[str, AbstractSession]
+    _webio_sessions = {}  # WebIOSessionID -> WebIOSession()
+    _webio_expire = LRUDict()  # WebIOSessionID -> last active timestamp。按照最后活跃时间递增排列
+
+    _last_check_session_expire_ts = 0  # 上次检查session有效期的时间戳
+
+    DEFAULT_SESSION_EXPIRE_SECONDS = 60  # 超过60s会话不活跃则视为会话过期
+    SESSIONS_CLEANUP_INTERVAL = 20  # 清理过期会话间隔(秒)
+    WAIT_MS_ON_POST = 100  # 在处理完POST请求时,等待WAIT_MS_ON_POST毫秒再读取返回数据。Task的command可以立即返回
+
+    @classmethod
+    def _remove_expired_sessions(cls, session_expire_seconds):
+        logger.debug("removing expired sessions")
+        """清除当前会话列表中的过期会话"""
+        while cls._webio_expire:
+            sid, active_ts = cls._webio_expire.popitem(last=False)
+
+            if time.time() - active_ts < session_expire_seconds:
+                # 当前session未过期
+                cls._webio_expire[sid] = active_ts
+                cls._webio_expire.move_to_end(sid, last=False)
+                break
+
+            # 清理session
+            logger.debug("session %s expired" % sid)
+            session = cls._webio_sessions.get(sid)
+            if session:
+                session.close()
+                del cls._webio_sessions[sid]
+
+    @classmethod
+    def _remove_webio_session(cls, sid):
+        cls._webio_sessions.pop(sid, None)
+        cls._webio_expire.pop(sid, None)
+
+    def _process_cors(self, context: HttpContext):
+        """处理跨域请求:检查请求来源并根据可访问性设置headers"""
+        origin = context.request_headers().get('Origin', '')
+        if self.check_origin(origin):
+            context.set_header('Access-Control-Allow-Origin', origin)
+            context.set_header('Access-Control-Allow-Methods', 'GET, POST')
+            context.set_header('Access-Control-Allow-Headers', 'content-type, webio-session-id')
+            context.set_header('Access-Control-Expose-Headers', 'webio-session-id')
+            context.set_header('Access-Control-Max-Age', str(1440 * 60))
+
+    def handle_request(self, context: HttpContext):
+        """处理请求"""
+        cls = type(self)
+
+        if _event_loop:
+            asyncio.set_event_loop(_event_loop)
+
+        request_headers = context.request_headers()
+
+        if context.request_method() == 'OPTIONS':  # preflight request for CORS
+            self._process_cors(context)
+            context.set_status(204)
+            return context.get_response()
+
+        if request_headers.get('Origin'):  # set headers for CORS request
+            self._process_cors(context)
+
+        if context.request_url_parameter('test'):  # 测试接口,当会话使用给予http的backend时,返回 ok
+            context.set_content('ok')
+            return context.get_response()
+
+        webio_session_id = None
+
+        # webio-session-id 的请求头为空时,创建新 Session
+        if 'webio-session-id' not in request_headers or not request_headers['webio-session-id']:
+            if context.request_method() == 'POST':  # 不能在POST请求中创建Session,防止CSRF攻击
+                context.set_status(403)
+                return context.get_response()
+
+            webio_session_id = random_str(24)
+            context.set_header('webio-session-id', webio_session_id)
+            webio_session = self.session_cls(self.target)
+            cls._webio_sessions[webio_session_id] = webio_session
+        elif request_headers['webio-session-id'] not in cls._webio_sessions:  # WebIOSession deleted
+            context.set_content([dict(command='close_session')], json_type=True)
+            return context.get_response()
+        else:
+            webio_session_id = request_headers['webio-session-id']
+            webio_session = cls._webio_sessions[webio_session_id]
+
+        if context.request_method() == 'POST':  # client push event
+            if context.request_json() is not None:
+                webio_session.send_client_event(context.request_json())
+                time.sleep(cls.WAIT_MS_ON_POST / 1000.0)
+        elif context.request_method() == 'GET':  # client pull messages
+            pass
+
+        cls._webio_expire[webio_session_id] = time.time()
+        # clean up at intervals
+        if time.time() - cls._last_check_session_expire_ts > self.session_cleanup_interval:
+            cls._last_check_session_expire_ts = time.time()
+            self._remove_expired_sessions(self.session_expire_seconds)
+
+        context.set_content(webio_session.get_task_commands(), json_type=True)
+
+        if webio_session.closed():
+            self._remove_webio_session(webio_session_id)
+
+        return context.get_response()
+
+    def __init__(self, target, session_cls,
+                 session_expire_seconds=None,
+                 session_cleanup_interval=None,
+                 allowed_origins=None, check_origin=None):
+        """获取用于与后端实现进行整合的view函数,基于http请求与前端进行通讯
+
+        :param target: 任务函数。任务函数为协程函数时,使用 :ref:`基于协程的会话实现 <coroutine_based_session>` ;任务函数为普通函数时,使用基于线程的会话实现。
+        :param int session_expire_seconds: 会话不活跃过期时间。
+        :param int session_cleanup_interval: 会话清理间隔。
+        :param list allowed_origins: 除当前域名外,服务器还允许的请求的来源列表。
+            来源包含协议和域名和端口部分,允许使用 Unix shell 风格的匹配模式:
+
+            - ``*`` 为通配符
+            - ``?`` 匹配单个字符
+            - ``[seq]`` 匹配seq内的字符
+            - ``[!seq]`` 匹配不在seq内的字符
+
+            比如 ``https://*.example.com`` 、 ``*://*.example.com``
+        :param callable check_origin: 请求来源检查函数。接收请求来源(包含协议和域名和端口部分)字符串,
+            返回 ``True/False`` 。若设置了 ``check_origin`` , ``allowed_origins`` 参数将被忽略
+        """
+        cls = type(self)
+
+        self.target = target
+        self.session_cls = session_cls
+        self.check_origin = check_origin
+        self.session_expire_seconds = session_expire_seconds or cls.DEFAULT_SESSION_EXPIRE_SECONDS
+        self.session_cleanup_interval = session_cleanup_interval or cls.SESSIONS_CLEANUP_INTERVAL
+
+        if check_origin is None:
+            self.check_origin = lambda origin: any(
+                fnmatch.fnmatch(origin, patten)
+                for patten in allowed_origins or []
+            )
+
+
+def run_event_loop(debug=False):
+    """运行事件循环
+
+    基于协程的会话在启动基于线程的http服务器之前需要启动一个单独的线程来运行事件循环。
+
+    :param debug: Set the debug mode of the event loop.
+       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)
+    _event_loop.run_forever()

+ 21 - 15
pywebio/platform/tornado.py

@@ -2,11 +2,11 @@ import asyncio
 import fnmatch
 import json
 import logging
+import os
 import threading
 import webbrowser
 from functools import partial
 from urllib.parse import urlparse
-import os
 
 import tornado
 import tornado.httpserver
@@ -14,6 +14,7 @@ import tornado.ioloop
 import tornado.websocket
 from tornado.web import StaticFileHandler
 from tornado.websocket import WebSocketHandler
+
 from ..session import CoroutineBasedSession, ThreadBasedSession, ScriptModeSession, \
     register_session_implement_for_target, AbstractSession
 from ..utils import get_free_port, wait_host_port, STATIC_PATH
@@ -98,7 +99,7 @@ def _webio_handler(target, session_cls, check_origin_func=_is_same_site):
 
 
 def webio_handler(target, allowed_origins=None, check_origin=None):
-    """获取用于Tornado进行整合的RequestHandle类
+    """获取在Tornado中运行PyWebIO任务的RequestHandle类。RequestHandle类基于WebSocket协议与浏览器进行通讯。
 
     :param target: 任务函数。任务函数为协程函数时,使用 :ref:`基于协程的会话实现 <coroutine_based_session>` ;任务函数为普通函数时,使用基于线程的会话实现。
     :param list allowed_origins: 除当前域名外,服务器还允许的请求的来源列表。
@@ -117,9 +118,7 @@ def webio_handler(target, allowed_origins=None, check_origin=None):
     session_cls = register_session_implement_for_target(target)
 
     if check_origin is None:
-        check_origin_func = _is_same_site
-        if allowed_origins:
-            check_origin_func = partial(_check_origin, allowed_origins=allowed_origins)
+        check_origin_func = partial(_check_origin, allowed_origins=allowed_origins or [])
     else:
         check_origin_func = lambda origin, handler: _is_same_site(origin, handler) or check_origin(origin)
 
@@ -157,12 +156,12 @@ def start_server(target, port=0, host='', debug=False,
                  websocket_ping_interval=None,
                  websocket_ping_timeout=None,
                  **tornado_app_settings):
-    """Start a Tornado server to serve `target` function
+    """启动一个 Tornado server 将 ``target`` 任务函数作为Web服务提供。
 
     :param target: 任务函数。任务函数为协程函数时,使用 :ref:`基于协程的会话实现 <coroutine_based_session>` ;任务函数为普通函数时,使用基于线程的会话实现。
     :param list allowed_origins: 除当前域名外,服务器还允许的请求的来源列表。
-    :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 int port: server bind port. set ``0`` to find a free port number to use
+    :param str host: server bind host. ``host`` may be either an IP address or hostname.  If it's a hostname,
         the server will listen on all IP addresses associated with the name.
         set empty string or to listen on all available interfaces.
     :param bool debug: Tornado debug mode
@@ -213,11 +212,13 @@ def start_server_in_current_thread_session():
 
     class SingleSessionWSHandler(_webio_handler(target=None, session_cls=None)):
         session = None
+        instance = None
 
         def open(self):
             self.main_session = False
             if SingleSessionWSHandler.session is None:
                 self.main_session = True
+                SingleSessionWSHandler.instance = self
                 SingleSessionWSHandler.session = ScriptModeSession(thread,
                                                                    on_task_command=self.send_msg_to_client,
                                                                    loop=asyncio.get_event_loop())
@@ -230,7 +231,7 @@ def start_server_in_current_thread_session():
                 self.session.close()
                 logger.debug('ScriptModeSession closed')
 
-    async def wait_to_stop_loop():
+    async def wait_to_stop_loop(server):
         """当只剩当前线程和Daemon线程运行时,关闭Server"""
         alive_none_daemonic_thread_cnt = None  # 包括当前线程在内的非Daemon线程数
         while alive_none_daemonic_thread_cnt != 1:
@@ -239,13 +240,18 @@ def start_server_in_current_thread_session():
             )
             await asyncio.sleep(1)
 
-        # 关闭ScriptModeSession。
-        # 主动关闭ioloop时,SingleSessionWSHandler.on_close 并不会被调用,需要手动关闭session
-        if SingleSessionWSHandler.session:
-            SingleSessionWSHandler.session.close()
+        # 关闭Websocket连接
+        if SingleSessionWSHandler.instance:
+            SingleSessionWSHandler.instance.close()
 
-        # Current thread is only one none-daemonic-thread, so exit
+        server.stop()
         logger.debug('Closing tornado ioloop...')
+        tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task() and not t.done()]
+        for task in tasks: task.cancel()
+
+        # 必须需要 await asyncio.sleep ,否则 t.cancel() 调用无法调度生效
+        await asyncio.sleep(0)
+
         tornado.ioloop.IOLoop.current().stop()
 
     def server_thread():
@@ -256,7 +262,7 @@ def start_server_in_current_thread_session():
         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(partial(wait_to_stop_loop, server=server))
         if "PYWEBIO_SCRIPT_MODE_PORT" not in os.environ:
             tornado.ioloop.IOLoop.current().spawn_callback(open_webbrowser_on_server_started, 'localhost', port)
 

+ 14 - 1
pywebio/session/__init__.py

@@ -31,6 +31,9 @@ def register_session_implement_for_target(target_func):
     else:
         cls = ThreadBasedSession
 
+    if ScriptModeSession in _active_session_cls:
+        raise RuntimeError("Already in script mode, can't start server")
+
     if cls not in _active_session_cls:
         _active_session_cls.append(cls)
 
@@ -159,6 +162,16 @@ def defer_call(func):
     可以用于资源清理等工作。
     在会话中可以多次调用 `defer_call()` ,会话结束后将会顺序执行设置的函数。
 
+    `defer_call` 同样支持以装饰器的方式使用::
+
+         @defer_call
+         def cleanup():
+            pass
+
     :param func: 话结束时调用的函数
+
+    .. attention:: 通过 `defer_call()` 设置的函数被调用时会话已经关闭,所以在函数体内不可以调用 PyWebIO 的交互函数
+
     """
-    return get_current_session().defer_call(func)
+    get_current_session().defer_call(func)
+    return func

+ 13 - 9
pywebio/session/coroutinebased.py

@@ -5,6 +5,7 @@ import threading
 import traceback
 from contextlib import contextmanager
 from functools import partial
+
 from .base import AbstractSession
 from ..exceptions import SessionNotFoundException, SessionClosedException, SessionException
 from ..utils import random_str, isgeneratorfunction, iscoroutinefunction, catch_exp_call
@@ -131,12 +132,7 @@ class CoroutineBasedSession(AbstractSession):
     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
+        return await WebIOFuture()
 
     def send_client_event(self, event):
         """向会话发送来自用户浏览器的事件️
@@ -157,7 +153,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.step(SessionClosedException, throw_exp=True)
             t.close()
         self.coros = {}  # delete session tasks
         CoroutineBasedSession._active_session_cnt -= 1
@@ -320,11 +316,19 @@ class Task:
 
         logger.debug('Task[%s] created ', self.coro_id)
 
-    def step(self, result=None):
+    def step(self, result=None, throw_exp=False):
+        """激活协程
+
+        :param any result: 向协程传入的数据
+        :param bool throw_exp: 是否向协程引发异常,为 True 时, result 参数为相应的异常对象
+        """
         coro_yield = None
         with self.session_context():
             try:
-                coro_yield = self.coro.send(result)
+                if throw_exp:
+                    coro_yield = self.coro.throw(result)
+                else:
+                    coro_yield = self.coro.send(result)
             except StopIteration as e:
                 if len(e.args) == 1:
                     self.result = e.args[0]

+ 2 - 2
pywebio/session/threadbased.py

@@ -7,7 +7,7 @@ from functools import wraps
 
 from .base import AbstractSession
 from ..exceptions import SessionNotFoundException, SessionClosedException, SessionException
-from ..utils import random_str, LimitedSizeQueue, isgeneratorfunction, iscoroutinefunction, catch_exp_call
+from ..utils import random_str, LimitedSizeQueue, isgeneratorfunction, iscoroutinefunction, catch_exp_call, get_function_name
 
 logger = logging.getLogger(__name__)
 
@@ -275,7 +275,7 @@ class ThreadBasedSession(AbstractSession):
             "not coroutine function or generator function. ")
 
         self._activate_callback_env()
-        callback_id = 'CB-%s-%s' % (getattr(callback, '__name__', ''), random_str(10))
+        callback_id = 'CB-%s-%s' % (get_function_name(callback, 'callback'), random_str(10))
         self.callbacks[callback_id] = (callback, serial_mode)
         return callback_id
 

+ 8 - 1
pywebio/utils.py

@@ -5,11 +5,12 @@ import queue
 import random
 import socket
 import string
-import time
 from collections import OrderedDict
 from contextlib import closing
 from os.path import abspath, dirname
 
+import time
+
 project_dir = dirname(abspath(__file__))
 
 STATIC_PATH = '%s/html' % project_dir
@@ -40,6 +41,12 @@ def isgeneratorfunction(object):
     return inspect.isgeneratorfunction(object)
 
 
+def get_function_name(func, default=None):
+    while isinstance(func, functools.partial):
+        func = func.func
+    return getattr(func, '__name__', default)
+
+
 class LimitedSizeQueue(queue.Queue):
     """
     有限大小的队列

+ 13 - 0
requirements.txt

@@ -1 +1,14 @@
 tornado>=4.3.0
+
+# extra support
+flask
+django
+aiohttp
+
+# test requirements
+selenium==3.*
+percy-python-selenium
+coverage
+
+# doc building requirements
+sphinx-tabs

+ 10 - 8
setup.py

@@ -1,4 +1,5 @@
 import os
+from functools import reduce
 
 from setuptools import setup, find_packages
 
@@ -11,6 +12,14 @@ with open(os.path.join(here, 'pywebio', '__version__.py')) as f:
 with open('README.md') as f:
     readme = f.read()
 
+extras_require = {
+    'flask': ['flask'],
+    'django': ['django'],
+    'aiohttp': ['aiohttp'],
+}
+# 可以使用 pip install pywebio[all] 安装所有额外依赖
+extras_require['all'] = reduce(lambda x, y: x + y, extras_require.values())
+
 setup(
     name=about['__package__'],
     version=about['__version__'],
@@ -68,14 +77,7 @@ setup(
     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',
-        ]
-    },
+    extras_require=extras_require,
     project_urls={
         'Documentation': 'https://pywebio.readthedocs.io',
         'Source': 'https://github.com/wang0618/PyWebIO',

+ 3 - 0
test/.percy.yml

@@ -0,0 +1,3 @@
+version: 1
+snapshot:
+  widths: [1000]

+ 13 - 5
test/2.basic_input.py → test/1.basic.py

@@ -1,12 +1,13 @@
 import subprocess
 
+import time
 from selenium.webdriver import Chrome
 
 import pywebio
 import template
 import util
 from pywebio import start_server
-from pywebio.input import actions
+from pywebio.input import *
 from pywebio.output import *
 from pywebio.utils import run_as_function
 
@@ -16,21 +17,28 @@ def target():
 
     template.set_defer_call()
 
-    run_as_function(template.basic_input())
+    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_input(browser)
+    template.test_output(browser, enable_percy=True)
+
+    template.test_input(browser, enable_percy=True)
+
+    time.sleep(1)
+    template.save_output(browser, '1.basic.html')
+
     template.test_defer_call()
 
 
 def start_test_server():
     pywebio.enable_debug()
-    start_server(target, port=8080, debug=False, auto_open_webbrowser=False)
+    start_server(target, port=8080, debug=True, auto_open_webbrowser=False)
 
 
 if __name__ == '__main__':

+ 0 - 33
test/1.basic_output.py

@@ -1,33 +0,0 @@
-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)

+ 62 - 0
test/10.aiohttp_multiple_session_impliment.py

@@ -0,0 +1,62 @@
+import subprocess
+
+import time
+from aiohttp import web
+from selenium.webdriver import Chrome
+
+import pywebio
+import template
+import util
+from pywebio import STATIC_PATH
+from pywebio.input import *
+from pywebio.platform.aiohttp import static_routes, webio_handler
+from pywebio.utils import to_coroutine, run_as_function
+
+
+def target():
+    template.basic_output()
+    template.background_output()
+
+    run_as_function(template.basic_input())
+    actions(buttons=['Continue'])
+    template.background_input()
+
+
+async def async_target():
+    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)
+    time.sleep(1)
+    template.test_input(browser)
+    time.sleep(1)
+    template.save_output(browser, '10.aiohttp_multiple_session_impliment_p1.html')
+
+    browser.get('http://localhost:8080?_pywebio_debug=1&pywebio_api=io2')
+    template.test_output(browser)
+    time.sleep(1)
+    template.test_input(browser)
+
+    time.sleep(1)
+    template.save_output(browser, '10.aiohttp_multiple_session_impliment_p2.html')
+
+
+def start_test_server():
+    pywebio.enable_debug()
+
+    app = web.Application()
+    app.add_routes([web.get('/io', webio_handler(target))])
+    app.add_routes([web.get('/io2', webio_handler(async_target))])
+    app.add_routes(static_routes(STATIC_PATH))
+
+    web.run_app(app, host='localhost', port=8080)
+
+
+if __name__ == '__main__':
+    util.run_test(start_test_server, test)

+ 6 - 9
test/3.script_mode.py → test/2.script_mode.py

@@ -13,31 +13,28 @@ 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)
+    template.test_output(browser)
 
     time.sleep(1)
 
-    template.test_input(browser, percy_prefix=percy_prefix)
+    template.test_input(browser)
 
     # script mode 下,此时 server 应停止
     server_proc.wait(timeout=8)
-    percySnapshot(browser=browser, name=percy_prefix + 'over')
+
+    time.sleep(1)
+    template.save_output(browser, '2.script_mode.html')
+
 
 
 if __name__ == '__main__':

+ 41 - 0
test/3.django_backend.py

@@ -0,0 +1,41 @@
+import subprocess
+
+import time
+from selenium.webdriver import Chrome
+
+import pywebio
+import template
+import util
+from pywebio.input import *
+from pywebio.platform.django import start_server
+from pywebio.utils import run_as_function
+
+
+def target():
+    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)
+
+    time.sleep(1)
+
+    template.test_input(browser)
+
+    time.sleep(1)
+    template.save_output(browser, '3.django_backend.html')
+
+
+def start_test_server():
+    pywebio.enable_debug()
+
+    start_server(target, port=8080)
+
+
+if __name__ == '__main__':
+    util.run_test(start_test_server, test)

+ 6 - 10
test/4.flask_backend.py

@@ -1,38 +1,34 @@
 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
+from pywebio.utils import 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()
 
 
 def test(server_proc: subprocess.Popen, browser: Chrome):
-    template.test_output(browser, percy_prefix='[flask]')
+    template.test_output(browser)
 
     time.sleep(1)
 
-    template.test_input(browser, percy_prefix='[flask]')
+    template.test_input(browser)
+
+    time.sleep(1)
+    template.save_output(browser, '4.flask_backend.html')
 
 
 def start_test_server():

+ 5 - 7
test/5.coroutine_based_session.py

@@ -14,25 +14,23 @@ 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]')
+    template.test_output(browser)
 
     time.sleep(1)
 
-    template.test_input(browser, percy_prefix='[coro]')
+    template.test_input(browser)
+
+    time.sleep(1)
+    template.save_output(browser, '5.coroutine_based_session.html')
 
 
 def start_test_server():

+ 5 - 7
test/6.flask_coroutine.py

@@ -13,25 +13,23 @@ 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]')
+    template.test_output(browser)
 
     time.sleep(1)
 
-    template.test_input(browser, percy_prefix='[flask coro]')
+    template.test_input(browser)
+
+    time.sleep(1)
+    template.save_output(browser, '6.flask_coroutine.html')
 
 
 def start_test_server():

+ 8 - 20
test/7.multiple_session_impliment.py

@@ -11,49 +11,37 @@ 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]')
-
+    template.test_output(browser)
     time.sleep(1)
-
-    template.test_input(browser, percy_prefix='[multi tornado coro]')
-
-    time.sleep(3)
+    template.test_input(browser)
+    time.sleep(1)
+    template.save_output(browser, '7.multiple_session_impliment_p1.html')
 
     browser.get('http://localhost:8080?_pywebio_debug=1&pywebio_api=io2')
-
-    template.test_output(browser, percy_prefix='[multi tornado thread]')
-
+    template.test_output(browser)
     time.sleep(1)
+    template.test_input(browser)
 
-    template.test_input(browser, percy_prefix='[multi tornado thread]')
+    time.sleep(1)
+    template.save_output(browser, '7.multiple_session_impliment_p2.html')
 
 
 def start_test_server():

+ 8 - 21
test/8.flask_multiple_session_impliment.py

@@ -12,50 +12,37 @@ 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]')
-
+    template.test_output(browser)
     time.sleep(1)
-
-    template.test_input(browser, percy_prefix='[multi flask coro]')
-
-    time.sleep(3)
+    template.test_input(browser)
+    time.sleep(1)
+    template.save_output(browser, '8.flask_multiple_session_impliment_p1.html')
 
     browser.get('http://localhost:8080?_pywebio_debug=1&pywebio_api=io2')
-
-    template.test_output(browser, percy_prefix='[multi flask thread]')
-
+    template.test_output(browser)
     time.sleep(1)
+    template.test_input(browser)
 
-    template.test_input(browser, percy_prefix='[multi flask thread]')
+    time.sleep(1)
+    template.save_output(browser, '8.flask_multiple_session_impliment_p2.html')
 
 
 def start_test_server():

+ 41 - 0
test/9.aiohttp_backend.py

@@ -0,0 +1,41 @@
+import subprocess
+
+import time
+from selenium.webdriver import Chrome
+
+import pywebio
+import template
+import util
+from pywebio.input import *
+from pywebio.platform.aiohttp import start_server
+from pywebio.utils import run_as_function
+
+
+def target():
+    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)
+
+    time.sleep(1)
+
+    template.test_input(browser)
+
+    time.sleep(1)
+    template.save_output(browser, '9.aiohttp_backend.html')
+
+
+def start_test_server():
+    pywebio.enable_debug()
+
+    start_server(target, port=8080)
+
+
+if __name__ == '__main__':
+    util.run_test(start_test_server, test)

+ 1 - 0
test/assets/helloworld.txt

@@ -0,0 +1 @@
+hello world

+ 22 - 0
test/output_diff.py

@@ -0,0 +1,22 @@
+import os, sys
+
+
+def diff_file(file_a, file_b):
+    if open(file_a).read() != open(file_b).read():
+        cmd = 'diff %s %s' % (file_a, file_b)
+        print('#' * 4, cmd, '#' * 4)
+        os.system(cmd)
+        return True
+    return False
+
+
+def diff_dir(dir):
+    files = [os.path.join(dir, f) for f in os.listdir(dir) if os.path.isfile(os.path.join(dir, f))]
+    has_diff = any(diff_file(files[idx - 1], files[idx]) for idx in range(1, len(files)))
+    if has_diff:
+        sys.exit(1)
+
+
+if __name__ == '__main__':
+    here_dir = os.path.dirname(os.path.abspath(__file__))
+    diff_dir(os.path.join(here_dir, 'output'))

+ 0 - 24
test/run_all.py

@@ -1,24 +0,0 @@
-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()

+ 12 - 9
test/run_all.sh

@@ -1,11 +1,14 @@
 #!/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
+set -o xtrace
+
+mkdir output
+
+exit_code=0
+
+for file in ./[0-9]*.*.py
+do
+  python3 "$file" auto || exit_code=1
+done
+
+python3 output_diff.py && exit "$exit_code"

+ 60 - 40
test/template.py

@@ -1,6 +1,7 @@
 import asyncio
 import json
 import os
+import re
 import threading
 from functools import partial
 from os import path
@@ -55,9 +56,6 @@ def basic_output():
     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'],
@@ -75,6 +73,16 @@ def basic_output():
         {"Course": "DB", "Score": "93"},
     ], header=["Course", "Score"], anchor='put_table')
 
+    put_text('code:')
+    put_code(json.dumps(dict(name='pywebio', author='wangweimin'), indent=4), 'json', anchor='scroll_basis')
+
+    put_text('move ⬆ code block to screen ... :')
+    put_buttons(buttons=[
+        ('BOTTOM', BOTTOM),
+        ('TOP', TOP),
+        ('MIDDLE', MIDDLE),
+    ], onclick=lambda pos: scroll_to('scroll_basis', pos), anchor='scroll_basis_btns')
+
     def edit_row(choice, row):
         put_text("You click %s button at row %s" % (choice, row), after='table_cell_buttons')
 
@@ -141,31 +149,37 @@ async def coro_background_output():
     return run_async(background())
 
 
-def test_output(browser: Chrome, percy_prefix=''):
+def test_output(browser: Chrome, enable_percy=False):
     """测试输出::
 
-        template.basic_output()
+        run template.basic_output()
+        run template.output_scroll()
         template.background_output() # 或者 await template.coro_background_output()
         hold()
 
     """
-    time.sleep(1)  # 等待输出完毕
-
-    if percy_prefix:
-        percy_prefix = percy_prefix + ' '
+    time.sleep(0.5)  # 等待输出完毕
 
+    # get focus
+    browser.find_element_by_tag_name('body').click()
+    time.sleep(0.5)
     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()
+        browser.execute_script("arguments[0].click();", btn)
 
     btns = browser.find_elements_by_css_selector('#pywebio-anchor-put_buttons button')
     for btn in btns:
         time.sleep(0.5)
-        btn.click()
+        browser.execute_script("arguments[0].click();", btn)
+
+    btns = browser.find_elements_by_css_selector('#pywebio-anchor-scroll_basis_btns button')
+    for btn in btns:
+        time.sleep(1)
+        browser.execute_script("arguments[0].click();", btn)
 
     time.sleep(1)
-    percySnapshot(browser=browser, name=percy_prefix + 'basic output')
+    enable_percy and percySnapshot(browser=browser, name='basic output')
 
 
 def basic_input():
@@ -189,7 +203,7 @@ def basic_input():
 
     # 文件上传
     img = yield file_upload("Select a image:", accept="image/*")
-    put_markdown(f'`{repr(img)}`')
+    put_image(img['content'], title=img['filename'])
 
     # 输入参数
     res = yield input('This is label', type=TEXT, placeholder='This is placeholder,required=True',
@@ -313,7 +327,7 @@ def basic_input():
     ], valid_func=check_form)
 
     put_text('`valid_func()` log:')
-    put_code(json.dumps(sorted(check_item_data), indent=4, ensure_ascii=False), 'json')
+    put_code(json.dumps(sorted(list(set(check_item_data))), indent=4, ensure_ascii=False), 'json')
 
     put_text('Form result:')
     if info:
@@ -360,17 +374,14 @@ async def flask_coro_background_input():
     put_markdown(f'`front: {repr(res)}`')
 
 
-def test_input(browser: Chrome, percy_prefix=''):
+def test_input(browser: Chrome, enable_percy=False):
     """测试输入::
 
-        template.basic_input()
+        run 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()
 
@@ -383,7 +394,7 @@ def test_input(browser: Chrome, percy_prefix=''):
 
     # checkbox
     time.sleep(0.5)
-    browser.find_element_by_css_selector('input').click()
+    browser.execute_script("arguments[0].click();", browser.find_element_by_css_selector('input'))
     browser.find_element_by_tag_name('form').submit()
 
     # Text Area
@@ -422,40 +433,42 @@ def test_input(browser: Chrome, percy_prefix=''):
 
     # 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_name('name').send_keys("name")
+    browser.find_element_by_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")
+    enable_percy and percySnapshot(browser=browser, name='input group invalid')
+
+    time.sleep(0.5)
+    browser.find_element_by_name('age').clear()
+    browser.find_element_by_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)))
+    time.sleep(1)
+    enable_percy and percySnapshot(browser=browser, name='input group all')
+    browser.find_element_by_name('text').send_keys("name")
+    browser.find_element_by_name('number').send_keys("20")
+    browser.find_element_by_name('float').send_keys("3.1415")
+    browser.find_element_by_name('password').send_keys("password")
+    browser.find_element_by_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)
+    Select(browser.find_element_by_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_name('file_upload').send_keys(path.join(here_dir, 'assets', 'helloworld.txt'))
 
-    browser.find_element_by_css_selector('button[value="submit2"]').click()
+    browser.execute_script("arguments[0].click();", browser.find_element_by_css_selector('button[value="submit2"]'))
     time.sleep(0.5)
-    percySnapshot(browser=browser, name=percy_prefix + 'input group all invalid')
+    enable_percy and percySnapshot(browser=browser, name='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()
+    browser.find_element_by_name('password').clear()
+    browser.find_element_by_name('password').send_keys("123")
+    browser.execute_script("arguments[0].click();", browser.find_element_by_css_selector('button[value="submit2"]'))
     time.sleep(0.5)
-    percySnapshot(browser=browser, name=percy_prefix + 'input group all submit')
+    enable_percy and percySnapshot(browser=browser, name='input group all submit')
 
     browser.find_element_by_css_selector('form').submit()
 
@@ -486,3 +499,10 @@ def test_defer_call():
     assert "deferred_2" in output
 
     os.remove('test_defer.tmp')
+
+
+def save_output(browser: Chrome, filename):
+    """获取输出区html源码,并去除随机元素"""
+    html = browser.find_element_by_id('markdown-body').get_attribute('innerHTML')
+    html = re.sub(r"WebIO.DisplayAreaButtonOnClick\(.*?\)", '', html)
+    open(path.join(here_dir, 'output', filename), 'w').write(html)

+ 1 - 0
test/util.py

@@ -55,6 +55,7 @@ def run_test(server_func, test_func, port=8080, chrome_options=None):
     browser = None
     try:
         browser = webdriver.Chrome(chrome_options=chrome_options)
+        browser.set_window_size(1000, 900)
         asyncio.run(wait_host_port('localhost', port))
         browser.get('http://localhost:%s?_pywebio_debug=1' % port)
         browser.implicitly_wait(10)