Skip to content

Commit

Permalink
feat: support FastAPI and Starlette
Browse files Browse the repository at this point in the history
  • Loading branch information
wang0618 committed Mar 27, 2021
1 parent de2d8df commit a988d03
Show file tree
Hide file tree
Showing 11 changed files with 337 additions and 12 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -205,7 +205,7 @@ PyWebIO还可以方便地整合进现有的Web服务,让你不需要编写HTML
- 使用同步而不是基于回调的方式获取输入,代码编写逻辑更自然
- 非声明式布局,布局方式简单高效
- 代码侵入性小,旧脚本代码仅需修改输入输出逻辑便可改造为Web服务
- 支持整合到现有的Web服务,目前支持与Flask、Django、Tornado、aiohttp框架集成
- 支持整合到现有的Web服务,目前支持与Flask、Django、Tornado、aiohttp、FastAPI框架集成
- 同时支持基于线程的执行模型和基于协程的执行模型
- 支持结合第三方库实现数据可视化

Expand Down
60 changes: 54 additions & 6 deletions docs/guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -728,7 +728,7 @@ You can use `defer_call(func) <pywebio.session.defer_call>` 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:

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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 <https://docs.aiohttp.org/en/stable/web_quickstart.html#aiohttp-web-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
Expand All @@ -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 <nginx_ws_config>` 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 <module>:app`` , visit ``http://localhost:8000/tool/`` to open the PyWebIO application

See also: `FastAPI doc <https://www.starlette.io/routing/#submounting-routes>`_ , `Starlette doc <https://fastapi.tiangolo.com/advanced/sub-applications/>`_

.. 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 <nginx_ws_config>` is an example of Nginx WebSocket configuration.


.. _integration_web_framework_note:

Notes
Expand Down
2 changes: 1 addition & 1 deletion docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions pywebio/platform/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
172 changes: 172 additions & 0 deletions pywebio/platform/fastapi.py
Original file line number Diff line number Diff line change
@@ -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)
26 changes: 25 additions & 1 deletion pywebio/platform/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion pywebio/session/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -122,6 +122,7 @@ def show():
* When using Flask, ``request`` is instance of `flask.Request <https://flask.palletsprojects.com/en/1.1.x/api/#incoming-request-data>`_
* When using Django, ``request`` is instance of `django.http.HttpRequest <https://docs.djangoproject.com/en/3.0/ref/request-response/#django.http.HttpRequest>`_
* When using aiohttp, ``request`` is instance of `aiohttp.web.BaseRequest <https://docs.aiohttp.org/en/stable/web_reference.html#aiohttp.web.BaseRequest>`_
* When using FastAPI/Starlette, ``request`` is instance of `starlette.websockets.WebSocket <https://www.starlette.io/websockets/>`_
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
Expand Down
11 changes: 10 additions & 1 deletion pywebio/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,4 +367,13 @@ def parse_file_size(size):
base = 2 ** (idx * 10)
return int(float(s) * base)

return int(size)
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)
Loading

0 comments on commit a988d03

Please sign in to comment.