page.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. from __future__ import annotations
  2. import asyncio
  3. import inspect
  4. import time
  5. import types
  6. import uuid
  7. from functools import wraps
  8. from typing import Callable, Dict, Generator, Optional
  9. import justpy as jp
  10. from addict import Dict as AdDict
  11. from pygments.formatters import HtmlFormatter
  12. from starlette.requests import Request
  13. from starlette.routing import compile_path
  14. from . import globals
  15. from .helpers import is_coroutine
  16. from .page_builder import PageBuilder
  17. from .routes import convert_arguments
  18. class Page(jp.QuasarPage):
  19. def __init__(self,
  20. title: Optional[str] = None,
  21. *,
  22. favicon: Optional[str] = None,
  23. dark: Optional[bool] = ...,
  24. classes: str = 'q-ma-md column items-start gap-4',
  25. css: str = HtmlFormatter().get_style_defs('.codehilite'),
  26. on_connect: Optional[Callable] = None,
  27. on_page_ready: Optional[Callable] = None,
  28. on_disconnect: Optional[Callable] = None,
  29. shared: bool = False,
  30. ) -> None:
  31. super().__init__()
  32. if globals.config:
  33. self.title = title or globals.config.title
  34. self.favicon = favicon or globals.config.favicon
  35. self.dark = dark if dark is not ... else globals.config.dark
  36. else:
  37. self.title = title
  38. self.favicon = favicon
  39. self.dark = dark if dark is not ... else None
  40. self.tailwind = True # use Tailwind classes instead of Quasars
  41. self.css = css
  42. self.connect_handler = on_connect
  43. self.page_ready_handler = on_page_ready
  44. self.page_ready_generator: Optional[Generator[None, None, None]] = None
  45. self.disconnect_handler = on_disconnect
  46. self.delete_flag = not shared
  47. self.waiting_javascript_commands: Dict[str, str] = {}
  48. self.on('result_ready', self.handle_javascript_result)
  49. self.on('page_ready', self.handle_page_ready)
  50. self.view = jp.Div(a=self, classes=classes, temp=False)
  51. self.view.add_page(self)
  52. async def _route_function(self, request: Request) -> Page:
  53. for handler in globals.connect_handlers + ([self.connect_handler] if self.connect_handler else []):
  54. arg_count = len(inspect.signature(handler).parameters)
  55. is_coro = is_coroutine(handler)
  56. if arg_count == 1:
  57. await handler(request) if is_coro else handler(request)
  58. elif arg_count == 0:
  59. await handler() if is_coro else handler()
  60. else:
  61. raise ValueError(f'invalid number of arguments (0 or 1 allowed, got {arg_count})')
  62. return self
  63. async def handle_page_ready(self, msg: AdDict) -> bool:
  64. with globals.within_view(self.view):
  65. if self.page_ready_generator is not None:
  66. if isinstance(self.page_ready_generator, types.AsyncGeneratorType):
  67. await self.page_ready_generator.__anext__()
  68. elif isinstance(self.page_ready_generator, types.GeneratorType):
  69. next(self.page_ready_generator)
  70. if self.page_ready_handler:
  71. arg_count = len(inspect.signature(self.page_ready_handler).parameters)
  72. is_coro = is_coroutine(self.page_ready_handler)
  73. if arg_count == 1:
  74. await self.page_ready_handler(msg.websocket) if is_coro else self.page_ready_handler(msg.websocket)
  75. elif arg_count == 0:
  76. await self.page_ready_handler() if is_coro else self.page_ready_handler()
  77. else:
  78. raise ValueError(f'invalid number of arguments (0 or 1 allowed, got {arg_count})')
  79. return False
  80. async def on_disconnect(self, websocket=None) -> None:
  81. for handler in globals.disconnect_handlers + ([self.disconnect_handler] if self.disconnect_handler else[]):
  82. arg_count = len(inspect.signature(handler).parameters)
  83. is_coro = is_coroutine(handler)
  84. if arg_count == 1:
  85. await handler(websocket) if is_coro else handler(websocket)
  86. elif arg_count == 0:
  87. await handler() if is_coro else handler()
  88. else:
  89. raise ValueError(f'invalid number of arguments (0 or 1 allowed, got {arg_count})')
  90. await super().on_disconnect(websocket)
  91. async def await_javascript(self, code: str, *, check_interval: float = 0.01, timeout: float = 1.0) -> str:
  92. start_time = time.time()
  93. request_id = str(uuid.uuid4())
  94. await self.run_javascript(code, request_id=request_id)
  95. while request_id not in self.waiting_javascript_commands:
  96. if time.time() > start_time + timeout:
  97. raise TimeoutError('JavaScript did not respond in time')
  98. await asyncio.sleep(check_interval)
  99. return self.waiting_javascript_commands.pop(request_id)
  100. def handle_javascript_result(self, msg) -> bool:
  101. self.waiting_javascript_commands[msg.request_id] = msg.result
  102. return False
  103. def add_head_html(self, html: str) -> None:
  104. for page in find_parent_view().pages.values():
  105. page.head_html += html
  106. def add_body_html(self, html: str) -> None:
  107. for page in find_parent_view().pages.values():
  108. page.body_html += html
  109. async def run_javascript(self, code: str) -> None:
  110. for page in find_parent_view().pages.values():
  111. assert isinstance(page, Page)
  112. if page.page_id not in jp.WebPage.sockets:
  113. raise RuntimeError('page not ready; use the `on_page_ready` argument: https://nicegui.io/#page')
  114. await page.run_javascript(code)
  115. async def await_javascript(self, code: str, *, check_interval: float = 0.01, timeout: float = 1.0) -> None:
  116. for page in find_parent_view().pages.values():
  117. assert isinstance(page, Page)
  118. if page.page_id not in jp.WebPage.sockets:
  119. raise RuntimeError('page not ready; use the `on_page_ready` argument: https://nicegui.io/#page')
  120. return await page.await_javascript(code, check_interval=check_interval, timeout=timeout)
  121. class page:
  122. def __init__(
  123. self,
  124. route: str,
  125. title: Optional[str] = None,
  126. *,
  127. favicon: Optional[str] = None,
  128. dark: Optional[bool] = ...,
  129. classes: str = 'q-ma-md column items-start gap-4',
  130. css: str = HtmlFormatter().get_style_defs('.codehilite'),
  131. on_connect: Optional[Callable] = None,
  132. on_page_ready: Optional[Callable] = None,
  133. on_disconnect: Optional[Callable] = None,
  134. shared: bool = False,
  135. ):
  136. """Page
  137. Creates a new page at the given route.
  138. :param route: route of the new page (path must start with '/')
  139. :param title: optional page title
  140. :param favicon: optional favicon
  141. :param dark: whether to use Quasar's dark mode (defaults to `dark` argument of `run` command)
  142. :param classes: tailwind classes for the container div (default: `'q-ma-md column items-start gap-4'`)
  143. :param css: CSS definitions
  144. :param on_connect: optional function or coroutine which is called for each new client connection
  145. :param on_page_ready: optional function or coroutine which is called when the websocket is connected; see `"Yield for Page Ready" <https://nicegui.io/#yield_for_page_ready>`_ as an alternative.
  146. :param on_disconnect: optional function or coroutine which is called when a client disconnects
  147. :param shared: whether the page instance is shared between multiple clients (default: `False`)
  148. """
  149. self.route = route
  150. self.title = title
  151. self.favicon = favicon
  152. self.dark = dark
  153. self.classes = classes
  154. self.css = css
  155. self.on_connect = on_connect
  156. self.on_page_ready = on_page_ready
  157. self.on_disconnect = on_disconnect
  158. self.shared = shared
  159. self.page: Optional[Page] = None
  160. *_, self.converters = compile_path(route)
  161. def __call__(self, func, **args) -> Callable:
  162. @wraps(func)
  163. async def decorated(request: Optional[Request] = None) -> Page:
  164. self.page = Page(
  165. title=self.title,
  166. favicon=self.favicon,
  167. dark=self.dark,
  168. classes=self.classes,
  169. css=self.css,
  170. on_connect=self.on_connect,
  171. on_page_ready=self.on_page_ready,
  172. on_disconnect=self.on_disconnect,
  173. shared=self.shared,
  174. )
  175. with globals.within_view(self.page.view):
  176. if 'request' in inspect.signature(func).parameters:
  177. if self.shared:
  178. globals.log.error('Cannot use `request` argument in shared page')
  179. return error(501)
  180. await self.connected(request)
  181. await self.header()
  182. args = convert_arguments(request, self.converters, func)
  183. result = await func(**args) if is_coroutine(func) else func(**args)
  184. if isinstance(result, types.GeneratorType):
  185. if self.shared:
  186. globals.log.error('Yielding for page_ready is not supported on shared pages')
  187. return error(501)
  188. next(result)
  189. if isinstance(result, types.AsyncGeneratorType):
  190. if self.shared:
  191. globals.log.error('Yielding for page_ready is not supported on shared pages')
  192. return error(501)
  193. await result.__anext__()
  194. self.page.page_ready_generator = result
  195. await self.footer()
  196. return self.page
  197. builder = PageBuilder(decorated, self.shared)
  198. if globals.state != globals.State.STOPPED:
  199. builder.create_route(self.route)
  200. globals.page_builders[self.route] = builder
  201. return decorated
  202. async def connected(self, request: Optional[Request]) -> None:
  203. pass
  204. async def header(self) -> None:
  205. pass
  206. async def footer(self) -> None:
  207. pass
  208. def find_parent_view() -> jp.HTMLBaseComponent:
  209. view_stack = globals.get_view_stack()
  210. if not view_stack:
  211. if globals.loop and globals.loop.is_running():
  212. raise RuntimeError('cannot find parent view, view stack is empty')
  213. page = Page(shared=True)
  214. view_stack.append(page.view)
  215. jp.Route('/', page._route_function)
  216. return view_stack[-1]
  217. def error(status_code: int) -> jp.QuasarPage:
  218. title = globals.config.title if globals.config else f'Error {status_code}'
  219. favicon = globals.config.favicon if globals.config else None
  220. dark = globals.config.dark if globals.config else False
  221. wp = jp.QuasarPage(title=title, favicon=favicon, dark=dark, tailwind=True)
  222. div = jp.Div(a=wp, classes='py-20 text-center')
  223. jp.Div(a=div, classes='text-8xl py-5', text='☹',
  224. style='font-family: "Arial Unicode MS", "Times New Roman", Times, serif;')
  225. jp.Div(a=div, classes='text-6xl py-5', text=status_code)
  226. if 400 <= status_code <= 499:
  227. message = "This page doesn't exist"
  228. elif 500 <= status_code <= 599:
  229. message = 'Server error'
  230. else:
  231. message = 'Unknown error'
  232. jp.Div(a=div, classes='text-xl py-5', text=message)
  233. return wp
  234. def init_auto_index_page() -> None:
  235. view_stack = globals.view_stacks.get(0)
  236. if not view_stack:
  237. return # there is no auto-index page on the view stack
  238. page: Page = view_stack.pop().pages[0]
  239. page.title = globals.config.title
  240. page.favicon = globals.config.favicon
  241. page.dark = globals.config.dark
  242. page.view.classes = globals.config.main_page_classes
  243. assert len(view_stack) == 0
  244. def create_page_routes() -> None:
  245. jp.Route("/{path:path}", lambda: error(404), last=True)
  246. for route, page_builder in globals.page_builders.items():
  247. page_builder.create_route(route)