page.py 11 KB

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