From a988d035395801a39f56362422020770b19e4099 Mon Sep 17 00:00:00 2001 From: wangweimin Date: Sat, 27 Mar 2021 19:11:29 +0800 Subject: [PATCH] feat: support FastAPI and Starlette --- README.md | 4 +- docs/guide.rst | 60 ++++++++++-- docs/index.rst | 2 +- pywebio/platform/__init__.py | 11 +++ pywebio/platform/fastapi.py | 172 +++++++++++++++++++++++++++++++++++ pywebio/platform/utils.py | 26 +++++- pywebio/session/__init__.py | 3 +- pywebio/utils.py | 11 ++- requirements.txt | 3 + test/17.fastapi_backend.py | 55 +++++++++++ test/template.py | 2 + 11 files changed, 337 insertions(+), 12 deletions(-) create mode 100644 pywebio/platform/fastapi.py create mode 100644 test/17.fastapi_backend.py diff --git a/README.md b/README.md index 7977c6b4..2bbc18c9 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Features: - Use synchronization instead of callback-based method to get input - Non-declarative layout, simple and efficient - Less intrusive: old script code can be transformed into a Web application only by modifying the input and output operation -- Support integration into existing web services, currently supports Flask, Django, Tornado, aiohttp framework +- Support integration into existing web services, currently supports Flask, Django, Tornado, aiohttp, FastAPI framework - Support for ``asyncio`` and coroutine - Support data visualization with third-party libraries, e.g., `plotly`, `bokeh`, `pyecharts`. @@ -205,7 +205,7 @@ PyWebIO还可以方便地整合进现有的Web服务,让你不需要编写HTML - 使用同步而不是基于回调的方式获取输入,代码编写逻辑更自然 - 非声明式布局,布局方式简单高效 - 代码侵入性小,旧脚本代码仅需修改输入输出逻辑便可改造为Web服务 -- 支持整合到现有的Web服务,目前支持与Flask、Django、Tornado、aiohttp框架集成 +- 支持整合到现有的Web服务,目前支持与Flask、Django、Tornado、aiohttp、FastAPI框架集成 - 同时支持基于线程的执行模型和基于协程的执行模型 - 支持结合第三方库实现数据可视化 diff --git a/docs/guide.rst b/docs/guide.rst index a8a5dd39..e543ea73 100644 --- a/docs/guide.rst +++ b/docs/guide.rst @@ -728,7 +728,7 @@ You can use `defer_call(func) ` to set the function Integration with web framework --------------------------------- -The PyWebIO application can be integrated into an existing Python Web project, the PyWebIO application and the Web project share a web framework. PyWebIO currently supports integration with Flask, Tornado, Django and aiohttp web frameworks. +The PyWebIO application can be integrated into an existing Python Web project, the PyWebIO application and the Web project share a web framework. PyWebIO currently supports integration with Flask, Tornado, Django, aiohttp and FastAPI(Starlette) web frameworks. The integration methods of those web frameworks are as follows: @@ -740,7 +740,7 @@ The integration methods of those web frameworks are as follows: **Tornado** - Need to add a ``RequestHandler`` to Tornado application:: + Use `pywebio.platform.tornado.webio_handler()` to get the ``RequestHandler`` class for running PyWebIO applications in Tornado:: import tornado.ioloop import tornado.web @@ -772,7 +772,7 @@ The integration methods of those web frameworks are as follows: **Flask** - One route need to be added to communicate with the browser through HTTP:: + Use `pywebio.platform.flask.webio_view()` to get the view function for running PyWebIO applications in Flask:: from pywebio.platform.flask import webio_view from pywebio import STATIC_PATH @@ -795,7 +795,7 @@ The integration methods of those web frameworks are as follows: **Django** - Need to add a route in ``urls.py``:: + Use `pywebio.platform.django.webio_view()` to get the view function for running PyWebIO applications in Django:: # urls.py @@ -822,10 +822,10 @@ The integration methods of those web frameworks are as follows: **aiohttp** - One route need to be added to communicate with the browser through WebSocket:: + Use `pywebio.platform.aiohttp.webio_handler()` to get the `Request Handler `_ coroutine for running PyWebIO applications in aiohttp:: from aiohttp import web - from pywebio.platform.aiohttp import static_routes, webio_handler + from pywebio.platform.aiohttp import webio_handler app = web.Application() # `task_func` is PyWebIO task function @@ -839,6 +839,54 @@ The integration methods of those web frameworks are as follows: PyWebIO uses the WebSocket protocol to communicate with the browser in aiohttp. If your aiohttp server is behind a reverse proxy (such as Nginx), you may need to configure the reverse proxy to support the WebSocket protocol. :ref:`Here ` is an example of Nginx WebSocket configuration. + + .. tab:: FastAPI/Starlette + + .. only:: latex + + **FastAPI/Starlette** + + Use `pywebio.platform.fastapi.webio_routes()` to get the FastAPI/Starlette routes for running PyWebIO applications. + You can mount the routes to your FastAPI/Starlette app. + + FastAPI:: + + from fastapi import FastAPI + from pywebio.platform.fastapi import webio_routes + + app = FastAPI() + + @app.get("/app") + def read_main(): + return {"message": "Hello World from main app"} + + # `task_func` is PyWebIO task function + app.mount("/tool", FastAPI(routes=webio_routes(task_func))) + + Starlette:: + + from starlette.applications import Starlette + from starlette.responses import JSONResponse + from starlette.routing import Route, Mount + from pywebio.platform.fastapi import webio_routes + + async def homepage(request): + return JSONResponse({'hello': 'world'}) + + app = Starlette(routes=[ + Route('/', homepage), + Mount('/tool', routes=webio_routes(task_func)) # `task_func` is PyWebIO task function + ]) + + After starting the server by using ``uvicorn :app`` , visit ``http://localhost:8000/tool/`` to open the PyWebIO application + + See also: `FastAPI doc `_ , `Starlette doc `_ + + .. attention:: + + PyWebIO uses the WebSocket protocol to communicate with the browser in FastAPI/Starlette. If your server is behind a reverse proxy (such as Nginx), you may need to configure the reverse proxy to support the WebSocket protocol. :ref:`Here ` is an example of Nginx WebSocket configuration. + + .. _integration_web_framework_note: Notes diff --git a/docs/index.rst b/docs/index.rst index b86f19f6..4a298412 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -9,7 +9,7 @@ Features - Use synchronization instead of callback-based method to get input - Non-declarative layout, simple and efficient - Less intrusive: old script code can be transformed into a Web service only by modifying the input and output operation -- Support integration into existing web services, currently supports Flask, Django, Tornado, aiohttp framework +- Support integration into existing web services, currently supports Flask, Django, Tornado, aiohttp and FastAPI(Starlette) framework - Support for ``asyncio`` and coroutine - Support data visualization with third-party libraries diff --git a/pywebio/platform/__init__.py b/pywebio/platform/__init__.py index 115425c4..392c935e 100644 --- a/pywebio/platform/__init__.py +++ b/pywebio/platform/__init__.py @@ -83,6 +83,17 @@ .. autofunction:: pywebio.platform.aiohttp.webio_handler .. autofunction:: pywebio.platform.aiohttp.start_server +FastAPI/Starlette support +^^^^^^^^^^^^^^^^^^^^^^^^^ + +When using the FastAPI/Starlette as PyWebIO backend server, you need to install FastAPI/Starlette by yourself. +Also other dependency packages are required. You can install them with the following command:: + + pip3 install -U fastapi starlette uvicorn aiofiles websockets + +.. autofunction:: pywebio.platform.fastapi.webio_routes +.. autofunction:: pywebio.platform.fastapi.start_server + Other -------------- .. autofunction:: pywebio.platform.seo diff --git a/pywebio/platform/fastapi.py b/pywebio/platform/fastapi.py new file mode 100644 index 00000000..1627de08 --- /dev/null +++ b/pywebio/platform/fastapi.py @@ -0,0 +1,172 @@ +import asyncio +import logging +from functools import partial + +import uvicorn +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import HTMLResponse +from starlette.routing import Route, WebSocketRoute, Mount + +from starlette.websockets import WebSocket +from starlette.websockets import WebSocketDisconnect + +from .tornado import open_webbrowser_on_server_started +from .utils import make_applications, render_page, cdn_validation, OriginChecker +from ..session import CoroutineBasedSession, ThreadBasedSession, register_session_implement_for_target, Session +from ..session.base import get_session_info_from_headers +from ..utils import get_free_port, STATIC_PATH, iscoroutinefunction, isgeneratorfunction, strip_space + +logger = logging.getLogger(__name__) + + +def _webio_routes(applications, cdn, check_origin_func): + """ + :param dict applications: dict of `name -> task function` + :param bool/str cdn: Whether to load front-end static resources from CDN + :param callable check_origin_func: check_origin_func(origin, host) -> bool + """ + + async def http_endpoint(request: Request): + origin = request.headers.get('origin') + if origin and not check_origin_func(origin=origin, host=request.headers.get('host')): + return HTMLResponse(status_code=403, content="Cross origin websockets not allowed") + + # Backward compatible + if request.query_params.get('test'): + return HTMLResponse(content="") + + app_name = request.query_params.get('app', 'index') + app = applications.get(app_name) or applications['index'] + html = render_page(app, protocol='ws', cdn=cdn) + return HTMLResponse(content=html) + + async def websocket_endpoint(websocket: WebSocket): + ioloop = asyncio.get_event_loop() + await websocket.accept() + + close_from_session_tag = False # session close causes websocket close + + def send_msg_to_client(session: Session): + for msg in session.get_task_commands(): + ioloop.create_task(websocket.send_json(msg)) + + def close_from_session(): + nonlocal close_from_session_tag + close_from_session_tag = True + ioloop.create_task(websocket.close()) + logger.debug("WebSocket closed from session") + + session_info = get_session_info_from_headers(websocket.headers) + session_info['user_ip'] = websocket.client.host or '' + session_info['request'] = websocket + session_info['backend'] = 'starlette' + session_info['protocol'] = 'websocket' + + app_name = websocket.query_params.get('app', 'index') + application = applications.get(app_name) or applications['index'] + + if iscoroutinefunction(application) or isgeneratorfunction(application): + session = CoroutineBasedSession(application, session_info=session_info, + on_task_command=send_msg_to_client, + on_session_close=close_from_session) + else: + session = ThreadBasedSession(application, session_info=session_info, + on_task_command=send_msg_to_client, + on_session_close=close_from_session, loop=ioloop) + + while True: + try: + msg = await websocket.receive_json() + except WebSocketDisconnect: + if not close_from_session_tag: + # close session because client disconnected to server + session.close(nonblock=True) + logger.debug("WebSocket closed from client") + break + + if msg is not None: + session.send_client_event(msg) + + return [ + Route("/", http_endpoint), + WebSocketRoute("/", websocket_endpoint) + ] + + +def webio_routes(applications, cdn=True, allowed_origins=None, check_origin=None): + """Get the FastAPI/Starlette routes for running PyWebIO applications. + + The API communicates with the browser using WebSocket protocol. + + The arguments of ``webio_routes()`` have the same meaning as for :func:`pywebio.platform.fastapi.start_server` + + :return: FastAPI/Starlette routes + """ + try: + import websockets + except Exception: + raise RuntimeError(strip_space(""" + Missing dependency package `websockets` for websocket support. + You can install it with the following command: + pip install websocket + """.strip(), n=8)) + + applications = make_applications(applications) + for target in applications.values(): + register_session_implement_for_target(target) + + cdn = cdn_validation(cdn, 'error') + + if check_origin is None: + check_origin_func = partial(OriginChecker.check_origin, allowed_origins=allowed_origins or []) + else: + check_origin_func = lambda origin, host: OriginChecker.is_same_site(origin, host) or check_origin(origin) + + return _webio_routes(applications=applications, cdn=cdn, check_origin_func=check_origin_func) + + +def start_server(applications, port=0, host='', + cdn=True, static_dir=None, debug=False, + allowed_origins=None, check_origin=None, + auto_open_webbrowser=False, + **uvicorn_settings): + """Start a FastAPI/Starlette server using uvicorn to provide the PyWebIO application as a web service. + + :param bool debug: Boolean indicating if debug tracebacks should be returned on errors. + :param uvicorn_settings: Additional keyword arguments passed to ``uvicorn.run``. + For details, please refer: https://www.uvicorn.org/settings/ + + The rest arguments of ``start_server()`` have the same meaning as for :func:`pywebio.platform.tornado.start_server` + """ + kwargs = locals() + + try: + from starlette.staticfiles import StaticFiles + except Exception: + raise RuntimeError(strip_space(""" + Missing dependency package `aiofiles` for static file serving. + You can install it with the following command: + pip install aiofiles + """.strip(), n=8)) + + if not host: + host = '0.0.0.0' + + if port == 0: + port = get_free_port() + + cdn = cdn_validation(cdn, 'warn') + if cdn is False: + cdn = '/pywebio_static' + + routes = webio_routes(applications, cdn=cdn, allowed_origins=allowed_origins, check_origin=check_origin) + routes.append(Mount('/static', app=StaticFiles(directory=static_dir), name="static")) + routes.append(Mount('/pywebio_static', app=StaticFiles(directory=STATIC_PATH), name="pywebio_static")) + + app = Starlette(routes=routes, debug=debug) + + if auto_open_webbrowser: + asyncio.get_event_loop().create_task(open_webbrowser_on_server_started('localhost', port)) + + uvicorn.run(app, host=host, port=port) diff --git a/pywebio/platform/utils.py b/pywebio/platform/utils.py index ed7ee847..698ba1b4 100644 --- a/pywebio/platform/utils.py +++ b/pywebio/platform/utils.py @@ -3,7 +3,8 @@ from collections.abc import Mapping, Sequence from functools import partial from os import path - +import fnmatch +from urllib.parse import urlparse from tornado import template from ..__version__ import __version__ as version @@ -164,6 +165,29 @@ def make_applications(applications): return applications +class OriginChecker: + + @classmethod + def check_origin(cls, origin, allowed_origins, host): + if cls.is_same_site(origin, host): + return True + + return any( + fnmatch.fnmatch(origin, patten) + for patten in allowed_origins + ) + + @staticmethod + 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 seo(title, description=None, app=None): """Set the SEO information of the PyWebIO application (web page information provided when indexed by search engines) diff --git a/pywebio/session/__init__.py b/pywebio/session/__init__.py index bce874c3..5a93cf30 100644 --- a/pywebio/session/__init__.py +++ b/pywebio/session/__init__.py @@ -113,7 +113,7 @@ def show(): * ``origin`` (str): Indicate where the user from. Including protocol, host, and port parts. Such as ``'http://localhost:8080'`` . It may be empty, but it is guaranteed to have a value when the user's page address is not under the server host. (that is, the host, port part are inconsistent with ``server_host``). * ``user_ip`` (str): User's ip address. - * ``backend`` (str): The current PyWebIO backend server implementation. The possible values are ``'tornado'``, ``'flask'``, ``'django'`` , ``'aiohttp'``. + * ``backend`` (str): The current PyWebIO backend server implementation. The possible values are ``'tornado'``, ``'flask'``, ``'django'`` , ``'aiohttp'`` , ``'starlette'``. * ``protocol`` (str): The communication protocol between PyWebIO server and browser. The possible values are ``'websocket'``, ``'http'`` * ``request`` (object): The request object when creating the current session. Depending on the backend server, the type of ``request`` can be: @@ -122,6 +122,7 @@ def show(): * When using Flask, ``request`` is instance of `flask.Request `_ * When using Django, ``request`` is instance of `django.http.HttpRequest `_ * When using aiohttp, ``request`` is instance of `aiohttp.web.BaseRequest `_ + * When using FastAPI/Starlette, ``request`` is instance of `starlette.websockets.WebSocket `_ The ``user_agent`` attribute of the session information object is parsed by the user-agents library. See https://github.com/selwin/python-user-agents#usage diff --git a/pywebio/utils.py b/pywebio/utils.py index 3148080b..8cef7d9c 100644 --- a/pywebio/utils.py +++ b/pywebio/utils.py @@ -367,4 +367,13 @@ def parse_file_size(size): base = 2 ** (idx * 10) return int(float(s) * base) - return int(size) \ No newline at end of file + return int(size) + + +def strip_space(text, n): + """strip n spaces of every line in text""" + lines = ( + i[n:] if (i[:n] == ' ' * n) else i + for i in text.splitlines() + ) + return '\n'.join(lines) diff --git a/requirements.txt b/requirements.txt index 1fe57c7d..5985ba27 100755 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,9 @@ user-agents flask django aiohttp +starlette +uvicorn[standard] +aiofiles bokeh pandas cutecharts diff --git a/test/17.fastapi_backend.py b/test/17.fastapi_backend.py new file mode 100644 index 00000000..e92588ef --- /dev/null +++ b/test/17.fastapi_backend.py @@ -0,0 +1,55 @@ +import subprocess +import time + +from selenium.webdriver import Chrome + +import pywebio +import template +import util +from pywebio.input import * +from pywebio.platform.fastapi import start_server +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, '17.fastapi_multiple_session_impliment_p1.html') + + browser.get('http://localhost:8080/?app=coroutine') + template.test_output(browser) + time.sleep(1) + template.test_input(browser) + + time.sleep(1) + template.save_output(browser, '17.fastapi_multiple_session_impliment_p2.html') + + +def start_test_server(): + pywebio.enable_debug() + + start_server(dict(thread=target, coroutine=async_target), port=8080, cdn=False) + + +if __name__ == '__main__': + util.run_test(start_test_server, test, 'http://localhost:8080/?app=thread') diff --git a/test/template.py b/test/template.py index 76e910c8..e4da3a58 100644 --- a/test/template.py +++ b/test/template.py @@ -207,11 +207,13 @@ def edit_row(choice, row): from flask import Request from tornado.httputil import HTTPServerRequest from aiohttp.web import BaseRequest + from starlette.websockets import WebSocket request_type = { 'tornado': HTTPServerRequest, 'flask': Request, 'django': HttpRequest, 'aiohttp': BaseRequest, + 'starlette': WebSocket, } request_ok = isinstance(session_info.request, request_type.get(session_info.backend)) if not request_ok: