page.py 12 KB

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