浏览代码

Merge branch 'dev' into feat_demo

wangweimin 5 年之前
父节点
当前提交
fded2739da

+ 3 - 3
docs/guide.rst

@@ -76,7 +76,7 @@ User's guide
 
 :ref:`这里 <codemirror_options>` 列举了一些常用的Codemirror选项
 
-更多Codemirror选项请见:https://codemirror.net/doc/manual.html#config
+完整的Codemirror选项请见:https://codemirror.net/doc/manual.html#config
 
 输入组
 ^^^^^^^
@@ -109,7 +109,7 @@ PyWebIO还支持一组输入, 返回结果为一个字典。`pywebio.input.input
 基本输出
 ^^^^^^^^^^^^^^
 
-PyWebIO也提供了一些便捷函数来输出表格,链接等格式::
+PyWebIO提供了一些便捷函数来输出表格、链接等格式::
 
     # 文本输出
     put_text("Hello world!")
@@ -169,7 +169,7 @@ PyWebIO把程序与用户的交互分成了输入和输出两部分:输入函
 
 你可以在任何输出函数中使用 ``before`` 参数将内容插入到指定的锚点之前,也可以使用 ``after`` 参数将内容插入到指定的锚点之后。
 
-你也可以在输出函数中传入 ``anchor`` 参数为输出的内容打上锚点
+在输出函数中使用 ``anchor`` 参数为当前的输出内容标记锚点,若锚点已经存在,则将锚点处的内容替换为当前内容
 
 以下代码展示了在输出函数中使用锚点::
 

+ 1 - 1
docs/index.rst

@@ -72,7 +72,7 @@ Documentation
    input
    output
    session
-   server
+   platform
    misc
    demos
 

+ 0 - 0
docs/server.rst → docs/platform.rst


+ 5 - 2
docs/spec.rst

@@ -190,6 +190,7 @@ output_ctl:
 输入控制
 
 命令 spec 字段:
+
 * title: 设定标题
 * output_fixed_height: 设置是否输出区固定高度
 * auto_scroll_bottom: 设置有新内容时是否自动滚动到底部
@@ -197,8 +198,10 @@ output_ctl:
 * clear_before
 * clear_after
 * clear_range:[,]
-* scroll_to
-    
+* scroll_to:
+* position: top/middle/bottom 与scroll_to一起出现, 表示滚动页面,让锚点位于屏幕可视区域顶部/中部/底部
+* remove: 将给定的锚点连同锚点处的内容移除
+
 Event
 ------------
 

+ 12 - 5
pywebio/input.py

@@ -21,6 +21,7 @@
    PyWebIO 根据是否在输入函数中传入 ``name`` 参数来判断输入函数是在 `input_group` 中还是被单独调用。
    所以当你想要单独调用一个输入函数时,请不要设置 ``name`` 参数;而在 `input_group` 中调用输入函数时,**务必提供** ``name`` 参数
 
+输入默认可以忽略,如果需要用户必须提供值,则需要在输入函数中传入 ``required=True`` ( ``checkbox()` 和 `acrions()` 不支持 ``required`` 参数)
 """
 
 import logging
@@ -292,7 +293,7 @@ def actions(label='', buttons=None, name=None, help_text=None):
     return single_input(item_spec, valid_func, lambda d: d)
 
 
-def file_upload(label='', accept=None, name=None, placeholder='Choose file', help_text=None, **other_html_attrs):
+def file_upload(label='', accept=None, name=None, placeholder='Choose file', required=None, help_text=None, **other_html_attrs):
     r"""文件上传。
 
     :param accept: 单值或列表, 表示可接受的文件类型。单值或列表项支持的形式有:
@@ -304,13 +305,17 @@ def file_upload(label='', accept=None, name=None, placeholder='Choose file', hel
           参考 https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types
 
     :type accept: str or list
-    :param - label, name, placeholder, help_text, other_html_attrs: 与 `input` 输入函数的同名参数含义一致
-    :return: 表示用户文件的字典,格式为: ``{'filename': 文件名, 'content':文件二进制数据(bytes object)}``
+    :param str placeholder: 未上传文件时,文件上传框内显示的文本
+    :param bool required: 是否必须要上传文件
+    :param - label, name, help_text, other_html_attrs: 与 `input` 输入函数的同名参数含义一致
+    :return: 用户没有上传文件时,返回 ``None`` ;上传文件返回dict: ``{'filename': 文件名, 'content':文件二进制数据(bytes object)}``
     """
     item_spec, valid_func = _parse_args(locals())
     item_spec['type'] = 'file'
 
-    def read_file(data):  # data: {'filename':, 'dataurl'}
+    def read_file(data):  # data: None or {'filename':, 'dataurl'}
+        if data is None:
+            return data
         header, encoded = data['dataurl'].split(",", 1)
         data['content'] = b64decode(encoded)
         return data
@@ -361,11 +366,13 @@ def input_group(label='', inputs=None, valid_func=None):
             "`inputs` value error in `input_group`. Did you forget to add `name` parameter in input function?")
 
         input_name = input_kwargs['item_spec']['name']
+        if input_name in preprocess_funcs:
+            raise ValueError("Can't use same `name`:%s in different input in input group!!" % input_name)
         preprocess_funcs[input_name] = input_kwargs['preprocess_func']
         item_valid_funcs[input_name] = input_kwargs['valid_func']
         spec_inputs.append(input_kwargs['item_spec'])
 
-    if all('auto_focus' not in i for i in spec_inputs):  # 每一个输入项都没有设置autofocus参数
+    if all('auto_focus' not in i for i in spec_inputs):  # 每一个输入项都没有设置auto_focus参数
         for i in spec_inputs:
             text_inputs = {TEXT, NUMBER, PASSWORD, SELECT}  # todo update
             if i.get('type') in text_inputs:

+ 2 - 2
pywebio/io_ctrl.py

@@ -83,8 +83,8 @@ def check_item(name, data, valid_func, preprocess_func):
     try:
         data = preprocess_func(data)
         error_msg = valid_func(data)
-    except:
-        # todo log warning
+    except Exception as e:
+        logger.warning('Get %r in valid_func for name:"%s"', e, name)
         error_msg = '字段内容不合法'
     if error_msg is not None:
         send_msg('update_input', dict(target_name=name, attributes={

+ 1 - 1
pywebio/output.py

@@ -42,7 +42,7 @@ from .io_ctrl import output_register_callback, send_msg
 try:
     from PIL.Image import Image as PILImage
 except ImportError:
-    PILImage = type('MockPILImage', (), {})
+    PILImage = type('MockPILImage', (), dict(__init__=None))
 
 TOP = 'top'
 MIDDLE = 'middle'

+ 7 - 6
pywebio/platform/flask.py

@@ -29,7 +29,7 @@ from typing import Dict
 from flask import Flask, request, jsonify, send_from_directory, Response
 
 from ..session import CoroutineBasedSession, get_session_implement, AbstractSession, \
-    set_session_implement_for_target
+    register_session_implement_for_target
 from ..utils import STATIC_PATH
 from ..utils import random_str, LRUDict
 
@@ -80,7 +80,7 @@ def cors_headers(origin, check_origin, headers=None):
     return headers
 
 
-def _webio_view(target, session_expire_seconds, check_origin):
+def _webio_view(target, session_cls, session_expire_seconds, check_origin):
     """
     :param target:
     :param session_expire_seconds:
@@ -103,11 +103,12 @@ def _webio_view(target, session_expire_seconds, check_origin):
         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
-        Session = get_session_implement()
-        webio_session = Session(target)
+        webio_session = session_cls(target)
         _webio_sessions[webio_session_id] = webio_session
         _webio_expire[webio_session_id] = time.time()
     elif request.headers['webio-session-id'] not in _webio_sessions:  # WebIOSession deleted
@@ -152,7 +153,7 @@ def webio_view(target, session_expire_seconds=DEFAULT_SESSION_EXPIRE_SECONDS, al
     :return: Flask视图函数
     """
 
-    set_session_implement_for_target(target)
+    session_cls = register_session_implement_for_target(target)
 
     if check_origin is None:
         check_origin = lambda origin: any(
@@ -160,7 +161,7 @@ def webio_view(target, session_expire_seconds=DEFAULT_SESSION_EXPIRE_SECONDS, al
             for patten in allowed_origins
         )
 
-    view_func = partial(_webio_view, target=target,
+    view_func = partial(_webio_view, target=target, session_cls=session_cls,
                         session_expire_seconds=session_expire_seconds,
                         check_origin=check_origin)
     view_func.__name__ = 'webio_view'

+ 12 - 9
pywebio/platform/tornado.py

@@ -13,8 +13,8 @@ import tornado.ioloop
 import tornado.websocket
 from tornado.web import StaticFileHandler
 from tornado.websocket import WebSocketHandler
-from ..session import CoroutineBasedSession, ThreadBasedSession, get_session_implement, ScriptModeSession, \
-    set_session_implement_for_target, AbstractSession
+from ..session import CoroutineBasedSession, ThreadBasedSession, ScriptModeSession, \
+    register_session_implement_for_target, AbstractSession
 from ..utils import get_free_port, wait_host_port, STATIC_PATH
 
 logger = logging.getLogger(__name__)
@@ -41,13 +41,14 @@ def _is_same_site(origin, handler: WebSocketHandler):
     return origin == host
 
 
-def _webio_handler(target, check_origin_func=_is_same_site):
+def _webio_handler(target, session_cls, check_origin_func=_is_same_site):
     """获取用于Tornado进行整合的RequestHandle类
 
     :param target: 任务函数
     :param callable check_origin_func: check_origin_func(origin, handler) -> bool
     :return: Tornado RequestHandle类
     """
+
     class WSHandler(WebSocketHandler):
 
         def check_origin(self, origin):
@@ -67,13 +68,15 @@ def _webio_handler(target, check_origin_func=_is_same_site):
 
             self._close_from_session_tag = False  # 由session主动关闭连接
 
-            if get_session_implement() is CoroutineBasedSession:
+            if session_cls is CoroutineBasedSession:
                 self.session = CoroutineBasedSession(target, on_task_command=self.send_msg_to_client,
                                                      on_session_close=self.close_from_session)
-            else:
+            elif session_cls is ThreadBasedSession:
                 self.session = ThreadBasedSession(target, on_task_command=self.send_msg_to_client,
                                                   on_session_close=self.close_from_session,
                                                   loop=asyncio.get_event_loop())
+            else:
+                raise RuntimeError("Don't support session type:%s" % session_cls)
 
         def on_message(self, message):
             data = json.loads(message)
@@ -90,6 +93,7 @@ def _webio_handler(target, check_origin_func=_is_same_site):
 
     return WSHandler
 
+
 def webio_handler(target, allowed_origins=None, check_origin=None):
     """获取用于Tornado进行整合的RequestHandle类
 
@@ -100,7 +104,7 @@ def webio_handler(target, allowed_origins=None, check_origin=None):
         返回 ``True/False`` 。若设置了 ``check_origin`` , ``allowed_origins`` 参数将被忽略
     :return: Tornado RequestHandle类
     """
-    set_session_implement_for_target(target)
+    session_cls = register_session_implement_for_target(target)
 
     if check_origin is None:
         check_origin_func = _is_same_site
@@ -109,8 +113,7 @@ def webio_handler(target, allowed_origins=None, check_origin=None):
     else:
         check_origin_func = lambda origin, handler: check_origin(origin)
 
-    return _webio_handler(target=target, check_origin_func=check_origin_func)
-
+    return _webio_handler(target=target, session_cls=session_cls, check_origin_func=check_origin_func)
 
 
 async def open_webbrowser_on_server_started(host, port):
@@ -188,7 +191,7 @@ def start_server_in_current_thread_session():
     websocket_conn_opened = threading.Event()
     thread = threading.current_thread()
 
-    class SingleSessionWSHandler(_webio_handler(target=None)):
+    class SingleSessionWSHandler(_webio_handler(target=None, session_cls=None)):
         session = None
 
         def open(self):

+ 35 - 16
pywebio/session/__init__.py

@@ -16,26 +16,44 @@ from .coroutinebased import CoroutineBasedSession
 from .threadbased import ThreadBasedSession, ScriptModeSession
 from ..exceptions import SessionNotFoundException
 
-_session_type = None
+# 当前进程中正在使用的会话实现的列表
+_active_session_cls = []
 
 __all__ = ['run_async', 'run_asyncio_coroutine', 'register_thread']
 
 
-def set_session_implement_for_target(target_func):
-    """根据target_func函数类型设置会话实现"""
-    global _session_type
+def register_session_implement_for_target(target_func):
+    """根据target_func函数类型注册会话实现,并返回会话实现"""
     if asyncio.iscoroutinefunction(target_func) or inspect.isgeneratorfunction(target_func):
-        _session_type = CoroutineBasedSession
+        cls = CoroutineBasedSession
     else:
-        _session_type = ThreadBasedSession
+        cls = ThreadBasedSession
+
+    if cls not in _active_session_cls:
+        _active_session_cls.append(cls)
+
+    return cls
 
 
 def get_session_implement():
-    global _session_type
-    if _session_type is None:
-        _session_type = ScriptModeSession
+    """获取当前会话实现。仅供内部实现使用。应在会话上下文中调用"""
+    if not _active_session_cls:
+        _active_session_cls.append(ScriptModeSession)
         _start_script_mode_server()
-    return _session_type
+
+    # 当前正在使用的会话实现只有一个
+    if len(_active_session_cls) == 1:
+        return _active_session_cls[0]
+
+    # 当前有多个正在使用的会话实现
+    for cls in _active_session_cls:
+        try:
+            cls.get_current_session()
+            return cls
+        except SessionNotFoundException:
+            pass
+
+    raise SessionNotFoundException
 
 
 def _start_script_mode_server():
@@ -55,16 +73,17 @@ def check_session_impl(session_type):
     def decorator(func):
         @wraps(func)
         def inner(*args, **kwargs):
-            now_impl = get_session_implement()
-            if not issubclass(now_impl,
-                              session_type):  # Check if 'now_impl' is a derived from session_type or is the same class
+            curr_impl = get_session_implement()
+
+            # Check if 'now_impl' is a derived from session_type or is the same class
+            if not issubclass(curr_impl, session_type):
                 func_name = getattr(func, '__name__', str(func))
                 require = getattr(session_type, '__name__', str(session_type))
-                now = getattr(now_impl, '__name__', str(now_impl))
+                curr = getattr(curr_impl, '__name__', str(curr_impl))
 
                 raise RuntimeError("Only can invoke `{func_name:s}` in {require:s} context."
-                                   " You are now in {now:s} context".format(func_name=func_name, require=require,
-                                                                            now=now))
+                                   " You are now in {curr:s} context".format(func_name=func_name, require=require,
+                                                                             curr=curr))
             return func(*args, **kwargs)
 
         return inner

+ 17 - 7
pywebio/session/coroutinebased.py

@@ -2,6 +2,7 @@ import asyncio
 import inspect
 import logging
 import sys
+import threading
 import traceback
 from contextlib import contextmanager
 
@@ -24,7 +25,7 @@ class WebIOFuture:
 
 
 class _context:
-    current_session = None  # type:"AsyncBasedSession"
+    current_session = None  # type:"CoroutineBasedSession"
     current_task_id = None
 
 
@@ -44,8 +45,9 @@ class CoroutineBasedSession(AbstractSession):
 
     @staticmethod
     def get_current_session() -> "CoroutineBasedSession":
-        if _context.current_session is None:
-            raise SessionNotFoundException("No current found in context!")
+        if _context.current_session is None or \
+                _context.current_session.session_thread_id != threading.current_thread().ident:
+            raise SessionNotFoundException("No session found in current context!")
         return _context.current_session
 
     @staticmethod
@@ -67,12 +69,20 @@ class CoroutineBasedSession(AbstractSession):
 
         self._on_task_command = on_task_command or (lambda _: None)
         self._on_session_close = on_session_close or (lambda: None)
+
+        # 当前会话未被Backend处理的消息
         self.unhandled_task_msgs = []
 
+        # 创建会话的线程id。当前会话只能在本线程中使用
+        self.session_thread_id = threading.current_thread().ident
+
+        # 会话内的协程任务
         self.coros = {}  # coro_task_id -> Task()
 
         self._closed = False
-        self._not_closed_coro_cnt = 1  # 当前会话未结束运行的协程数量。当 self._not_closed_coro_cnt == 0 时,会话结束。
+
+        # 当前会话未结束运行(已创建和正在运行的)的协程数量。当 _alive_coro_cnt 变为 0 时,会话结束。
+        self._alive_coro_cnt = 1
 
         main_task = Task(target(), session=self, on_coro_stop=self._on_task_finish)
         self.coros[main_task.coro_id] = main_task
@@ -83,13 +93,13 @@ class CoroutineBasedSession(AbstractSession):
         task.step(result)
 
     def _on_task_finish(self, task: "Task"):
-        self._not_closed_coro_cnt -= 1
+        self._alive_coro_cnt -= 1
 
         if task.coro_id in self.coros:
             logger.debug('del self.coros[%s]', task.coro_id)
             del self.coros[task.coro_id]
 
-        if self._not_closed_coro_cnt <= 0 and not self.closed():
+        if self._alive_coro_cnt <= 0 and not self.closed():
             self.send_task_command(dict(command='close_session'))
             self._on_session_close()
             self.close()
@@ -197,7 +207,7 @@ class CoroutineBasedSession(AbstractSession):
         :param coro_obj: 协程对象
         :return: An instance of  `TaskHandle` is returned, which can be used later to close the task.
         """
-        self._not_closed_coro_cnt += 1
+        self._alive_coro_cnt += 1
 
         task = Task(coro_obj, session=self, on_coro_stop=self._on_task_finish)
         self.coros[task.coro_id] = task