page.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. import json
  2. import urllib.parse
  3. from collections import namedtuple
  4. from collections.abc import Mapping, Sequence
  5. from functools import lru_cache
  6. from functools import partial
  7. from os import path, environ
  8. from tornado import template
  9. from ..__version__ import __version__ as version
  10. from ..utils import isgeneratorfunction, iscoroutinefunction, get_function_name, get_function_doc, \
  11. get_function_attr, STATIC_PATH
  12. """
  13. The maximum size in bytes of a http request body or a websocket message, after which the request or websocket is aborted
  14. Set by `start_server()` or `path_deploy()`
  15. Used in `file_upload()` as the `max_size`/`max_total_size` parameter default or to validate the parameter.
  16. """
  17. MAX_PAYLOAD_SIZE = 0
  18. DEFAULT_CDN = "https://cdn.jsdelivr.net/gh/wang0618/PyWebIO-assets@v{version}/"
  19. _global_config = {'title': 'PyWebIO Application'}
  20. config_keys = ['title', 'description', 'js_file', 'js_code', 'css_style', 'css_file', 'theme', 'manifest']
  21. AppMeta = namedtuple('App', config_keys)
  22. _here_dir = path.dirname(path.abspath(__file__))
  23. _index_page_tpl = template.Template(open(path.join(_here_dir, 'tpl', 'index.html'), encoding='utf8').read())
  24. def render_page(app, protocol, cdn):
  25. """渲染前端页面的HTML框架, 支持SEO
  26. :param callable app: PyWebIO app
  27. :param str protocol: 'ws'/'http'
  28. :param bool/str cdn: Whether to use CDN, also accept string as custom CDN URL
  29. :return: bytes content of rendered page
  30. """
  31. assert protocol in ('ws', 'http')
  32. meta = parse_app_metadata(app)
  33. if cdn is True:
  34. base_url = DEFAULT_CDN.format(version=version)
  35. elif not cdn:
  36. base_url = ''
  37. else: # user custom cdn
  38. base_url = cdn.rstrip('/') + '/'
  39. manifest = manifest_tag(base_url, meta)
  40. theme = environ.get('PYWEBIO_THEME', meta.theme) or 'default'
  41. check_theme(theme)
  42. return _index_page_tpl.generate(title=meta.title, description=meta.description, protocol=protocol,
  43. script=True, content='', base_url=base_url, version=version,
  44. js_file=meta.js_file or [], js_code=meta.js_code, css_style=meta.css_style,
  45. css_file=meta.css_file or [], theme=theme, manifest=manifest)
  46. @lru_cache(maxsize=64)
  47. def check_theme(theme):
  48. """check theme file existence"""
  49. if not theme:
  50. return
  51. theme_file = path.join(STATIC_PATH, 'css', 'bs-theme', theme + '.min.css')
  52. if not path.isfile(theme_file):
  53. raise RuntimeError("Can't find css file for theme `%s`" % theme)
  54. def parse_app_metadata(func) -> AppMeta:
  55. """Get metadata form pywebio task function, fallback to global config in empty meta field."""
  56. prefix = '_pywebio_'
  57. attrs = get_function_attr(func, [prefix + k for k in config_keys])
  58. meta = AppMeta(**{k: attrs.get(prefix + k) for k in config_keys})
  59. doc = get_function_doc(func)
  60. parts = doc.strip().split('\n\n', 1)
  61. if len(parts) == 2:
  62. title, description = parts
  63. else:
  64. title, description = parts[0], ''
  65. if not title:
  66. title = get_function_name(func)
  67. if not meta.title:
  68. meta = meta._replace(title=title, description=description)
  69. # fallback to global config
  70. for key in config_keys:
  71. if not getattr(meta, key, None) and _global_config.get(key):
  72. kwarg = {key: _global_config.get(key)}
  73. meta = meta._replace(**kwarg)
  74. return meta
  75. _app_list_tpl = template.Template("""
  76. <h1>Applications index</h1>
  77. <ul>
  78. {% for name,meta in apps_info.items() %}
  79. <li>
  80. {% if other_arguments is not None %}
  81. <a href="?app={{name}}{{other_arguments}}">{{ meta.title or name }}</a>:
  82. {% else %}
  83. <a href="javascript:WebIO.openApp('{{ name }}', true)">{{ meta.title or name }}</a>:
  84. {% end %}
  85. {% if meta.description %}
  86. {{ meta.description }}
  87. {% else %}
  88. <i>No description.</i>
  89. {% end %}
  90. </li>
  91. {% end %}
  92. </ul>
  93. """.strip())
  94. def get_static_index_content(apps, query_arguments=None):
  95. """生成默认的静态主页
  96. :param callable apps: PyWebIO apps
  97. :param str query_arguments: Url Query Arguments。为None时,表示使用WebIO.openApp跳转
  98. :return: bytes
  99. """
  100. apps_info = {
  101. name: parse_app_metadata(func)
  102. for name, func in apps.items()
  103. }
  104. qs = urllib.parse.parse_qs(query_arguments)
  105. qs.pop('app', None)
  106. other_arguments = urllib.parse.urlencode(qs, doseq=True)
  107. if other_arguments:
  108. other_arguments = '&' + other_arguments
  109. else:
  110. other_arguments = None
  111. content = _app_list_tpl.generate(apps_info=apps_info, other_arguments=other_arguments).decode('utf8')
  112. return content
  113. def _generate_default_index_app(apps):
  114. """默认的主页任务函数"""
  115. content = get_static_index_content(apps)
  116. def index():
  117. from pywebio.output import put_html
  118. put_html(content)
  119. return index
  120. def make_applications(applications):
  121. """格式化 applications 为 任务名->任务函数 的映射, 并提供默认主页
  122. :param applications: 接受 单一任务函数、字典、列表 类型
  123. :return dict: 任务名->任务函数 的映射
  124. """
  125. if isinstance(applications, Sequence): # 列表 类型
  126. applications, app_list = {}, applications
  127. for func in app_list:
  128. name = get_function_name(func)
  129. if name in applications:
  130. raise ValueError("Duplicated application name:%r" % name)
  131. applications[name] = func
  132. elif not isinstance(applications, Mapping): # 单一任务函数 类型
  133. applications = {'index': applications}
  134. # convert dict key to str
  135. applications = {str(k): v for k, v in applications.items()}
  136. for app in applications.values():
  137. assert iscoroutinefunction(app) or isgeneratorfunction(app) or callable(app), \
  138. "Don't support application type:%s" % type(app)
  139. if 'index' not in applications:
  140. applications['index'] = _generate_default_index_app(applications)
  141. return applications
  142. def seo(title, description=None, app=None):
  143. """Set the SEO information of the PyWebIO application (web page information provided when indexed by search engines)
  144. :param str title: Application title
  145. :param str description: Application description
  146. :param callable app: PyWebIO task function
  147. If ``seo()`` is not used, the `docstring <https://www.python.org/dev/peps/pep-0257/>`_ of the task function will be regarded as SEO information by default.
  148. ``seo()`` can be used in 2 ways: direct call and decorator::
  149. @seo("title", "description")
  150. def foo():
  151. pass
  152. def bar():
  153. pass
  154. def hello():
  155. \"""Application title
  156. Application description...
  157. (A empty line is used to separate the description and title)
  158. \"""
  159. start_server([
  160. foo,
  161. hello,
  162. seo("title", "description", bar),
  163. ])
  164. .. versionadded:: 1.1
  165. .. deprecated:: 1.4
  166. Use :func:`pywebio.config` instead.
  167. """
  168. import warnings
  169. warnings.warn("`pywebio.platform.seo()` is deprecated since v1.4 and will remove in the future version, "
  170. "use `pywebio.config` instead", DeprecationWarning, stacklevel=2)
  171. if app is not None:
  172. return config(title=title, description=description)(app)
  173. return config(title=title, description=description)
  174. def manifest_tag(base_url, meta: AppMeta):
  175. """Generate inline web app manifest
  176. https://stackoverflow.com/questions/46221528/inline-the-web-app-manifest
  177. """
  178. if meta.manifest is False:
  179. return ""
  180. manifest_ = meta.manifest or {}
  181. if manifest_ is True:
  182. manifest_ = {}
  183. manifest = {
  184. "name": meta.title,
  185. "description": meta.description,
  186. "start_url": ".",
  187. "display": "standalone",
  188. "theme_color": "white",
  189. "background_color": "white",
  190. "icons": [
  191. {"src": f"{base_url}image/apple-touch-icon.png", "type": "image/png", "sizes": "180x180"},
  192. ]
  193. }
  194. manifest.update(manifest_)
  195. icon = manifest.pop("icon", None)
  196. if not icon:
  197. icon = base_url + 'image/apple-touch-icon.png'
  198. manifest_encode = urllib.parse.quote(json.dumps(manifest))
  199. tag = f"""<link rel="apple-touch-icon" href="{icon}">
  200. <link rel="manifest" href='data:application/manifest+json,{manifest_encode}' />"""
  201. return tag
  202. def config(*, title=None, description=None, theme=None, js_code=None, js_file=[], css_style=None, css_file=[],
  203. manifest=True):
  204. """PyWebIO application configuration
  205. :param str title: Application title
  206. :param str description: Application description
  207. :param str theme: Application theme. Available themes are: ``dark``, ``sketchy``, ``minty``, ``yeti``.
  208. You can also use environment variable ``PYWEBIO_THEME`` to specify the theme (with high priority).
  209. :demo_host:`Theme preview demo </theme>`
  210. .. collapse:: Open Source Credits
  211. The dark theme is modified from ForEvolve's `bootstrap-dark <https://github.com/ForEvolve/bootstrap-dark>`_.
  212. The sketchy, minty and yeti theme are from `bootswatch <https://bootswatch.com/4/>`_.
  213. :param str js_code: The javascript code that you want to inject to page.
  214. :param str/list js_file: The javascript files that inject to page, can be a URL in str or a list of it.
  215. :param str css_style: The CSS style that you want to inject to page.
  216. :param str/list css_file: The CSS files that inject to page, can be a URL in str or a list of it.
  217. :param bool/dict manifest: `Web application manifest <https://developer.mozilla.org/en-US/docs/Web/Manifest>`_ configuration.
  218. This feature allows you to add a shortcut to the home screen of your mobile device, and launch the app like a native app.
  219. If set to ``True``, the default manifest will be used. You can also specify the manifest content in dict.
  220. If ``False``, the manifest will be disabled.
  221. .. collapse:: Note for icon configuration
  222. Currently, the `icons <https://developer.mozilla.org/en-US/docs/Web/Manifest/icons>`_ field of the manifest
  223. is not supported. Instead, you can use the ``icon`` field to specify the icon url.
  224. ``config()`` can be used in 2 ways: direct call and decorator.
  225. If you call ``config()`` directly, the configuration will be global.
  226. If you use ``config()`` as decorator, the configuration will only work on single PyWebIO application function.
  227. ::
  228. config(title="My application") # global configuration
  229. @config(css_style="* { color:red }") # only works on this application
  230. def app():
  231. put_text("hello PyWebIO")
  232. .. note:: The configuration will affect all sessions
  233. ``title`` and ``description`` are used for SEO, which are provided when indexed by search engines.
  234. If no ``title`` and ``description`` set for a PyWebIO application function,
  235. the `docstring <https://www.python.org/dev/peps/pep-0257/>`_ of the function will be used as title and description by default::
  236. def app():
  237. \"""Application title
  238. Application description...
  239. (A empty line is used to separate the description and title)
  240. \"""
  241. pass
  242. The above code is equal to::
  243. @config(title="Application title", description="Application description...")
  244. def app():
  245. pass
  246. .. versionadded:: 1.4
  247. .. versionchanged:: 1.5
  248. add ``theme`` parameter
  249. """
  250. if isinstance(js_file, str):
  251. js_file = [js_file]
  252. if isinstance(css_file, str):
  253. css_file = [css_file]
  254. configs = locals()
  255. class Decorator:
  256. def __init__(self):
  257. self.called = False
  258. def __call__(self, func):
  259. self.called = True
  260. try:
  261. func = partial(func) # to make a copy of the function
  262. for key, val in configs.items():
  263. if val:
  264. setattr(func, '_pywebio_%s' % key, val)
  265. except Exception:
  266. pass
  267. return func
  268. def __del__(self): # if not called as decorator, set the config to global
  269. if self.called:
  270. return
  271. global _global_config
  272. _global_config = configs
  273. return Decorator()