page.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  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, List, 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.responses import FileResponse
  14. from starlette.routing import Route, compile_path
  15. from starlette.websockets import WebSocket
  16. from . import globals
  17. from .auto_context import Context, get_view_stack
  18. from .events import PageEvent
  19. from .helpers import is_coroutine
  20. from .page_builder import PageBuilder
  21. from .routes import add_route, convert_arguments
  22. class Page(jp.QuasarPage):
  23. def __init__(self,
  24. title: Optional[str] = None,
  25. *,
  26. favicon: Optional[str] = None,
  27. dark: Optional[bool] = ...,
  28. classes: str = 'q-pa-md column items-start gap-4',
  29. css: str = HtmlFormatter().get_style_defs('.codehilite'),
  30. on_connect: Optional[Callable] = None,
  31. on_page_ready: Optional[Callable] = None,
  32. on_disconnect: Optional[Callable] = None,
  33. shared: bool = False,
  34. ) -> None:
  35. super().__init__()
  36. if globals.config:
  37. self.title = title or globals.config.title
  38. self.set_favicon(favicon or globals.config.favicon)
  39. self.dark = dark if dark is not ... else globals.config.dark
  40. else:
  41. self.title = title
  42. self.set_favicon(favicon)
  43. self.dark = dark if dark is not ... else None
  44. self.tailwind = True # use Tailwind classes instead of Quasars
  45. self.css = css
  46. self.connect_handler = on_connect
  47. self.page_ready_handler = on_page_ready
  48. self.page_ready_generator: Optional[Generator[None, PageEvent, None]] = None
  49. self.disconnect_handler = on_disconnect
  50. self.shared = shared
  51. self.delete_flag = not shared
  52. self.waiting_javascript_commands: Dict[str, str] = {}
  53. self.on('result_ready', self.handle_javascript_result)
  54. self.on('page_ready', self.handle_page_ready)
  55. self.layout = jp.QLayout(a=self, view='HHH LpR FFF', temp=False)
  56. container = jp.QPageContainer(a=self.layout, temp=False)
  57. self.view = jp.Div(a=container, classes=classes, temp=False)
  58. self.view.add_page(self)
  59. def set_favicon(self, favicon: Optional[str]) -> None:
  60. if not favicon:
  61. self.favicon = 'favicon.ico'
  62. elif favicon.startswith('http://') or favicon.startswith('https://'):
  63. self.favicon = favicon
  64. else:
  65. self.favicon = f'_favicon/{favicon}'
  66. async def _route_function(self, request: Request) -> Page:
  67. with Context(self.view):
  68. for handler in globals.connect_handlers + ([self.connect_handler] if self.connect_handler else []):
  69. arg_count = len(inspect.signature(handler).parameters)
  70. is_coro = is_coroutine(handler)
  71. if arg_count == 1:
  72. await handler(request) if is_coro else handler(request)
  73. elif arg_count == 0:
  74. await handler() if is_coro else handler()
  75. else:
  76. raise ValueError(f'invalid number of arguments (0 or 1 allowed, got {arg_count})')
  77. return self
  78. async def handle_page_ready(self, msg: AdDict) -> bool:
  79. with Context(self.view) as context:
  80. try:
  81. if self.page_ready_generator is not None:
  82. if isinstance(self.page_ready_generator, types.AsyncGeneratorType):
  83. await context.watch_asyncs(self.page_ready_generator.asend(PageEvent(msg.websocket)))
  84. elif isinstance(self.page_ready_generator, types.GeneratorType):
  85. self.page_ready_generator.send(PageEvent(msg.websocket))
  86. except (StopIteration, StopAsyncIteration):
  87. pass # after the page_ready_generator returns, it will raise StopIteration; it's part of the generator protocol and expected
  88. except:
  89. globals.log.exception('Failed to execute page-ready')
  90. try:
  91. if self.page_ready_handler:
  92. arg_count = len(inspect.signature(self.page_ready_handler).parameters)
  93. is_coro = is_coroutine(self.page_ready_handler)
  94. if arg_count == 1:
  95. result = self.page_ready_handler(msg.websocket)
  96. elif arg_count == 0:
  97. result = self.page_ready_handler()
  98. else:
  99. raise ValueError(f'invalid number of arguments (0 or 1 allowed, got {arg_count})')
  100. if is_coro:
  101. await context.watch_asyncs(result)
  102. except:
  103. globals.log.exception('Failed to execute page-ready')
  104. return False
  105. async def on_disconnect(self, websocket: Optional[WebSocket] = None) -> None:
  106. with Context(self.view):
  107. for handler in globals.disconnect_handlers + ([self.disconnect_handler] if self.disconnect_handler else[]):
  108. arg_count = len(inspect.signature(handler).parameters)
  109. is_coro = is_coroutine(handler)
  110. if arg_count == 1:
  111. await handler(websocket) if is_coro else handler(websocket)
  112. elif arg_count == 0:
  113. await handler() if is_coro else handler()
  114. else:
  115. raise ValueError(f'invalid number of arguments (0 or 1 allowed, got {arg_count})')
  116. await super().on_disconnect(websocket)
  117. async def run_javascript_on_socket(self, code: str, websocket: WebSocket, *,
  118. respond: bool = True, timeout: float = 1.0, check_interval: float = 0.01) -> Optional[str]:
  119. start_time = time.time()
  120. request_id = str(uuid.uuid4())
  121. await websocket.send_json({'type': 'run_javascript', 'data': code, 'request_id': request_id, 'send': respond})
  122. if not respond:
  123. return
  124. while request_id not in self.waiting_javascript_commands:
  125. if time.time() > start_time + timeout:
  126. raise TimeoutError('JavaScript did not respond in time')
  127. await asyncio.sleep(check_interval)
  128. return self.waiting_javascript_commands.pop(request_id)
  129. async def run_javascript(self, code: str, *,
  130. respond: bool = True, timeout: float = 1.0, check_interval: float = 0.01) -> Dict[WebSocket, Optional[str]]:
  131. if self.page_id not in jp.WebPage.sockets:
  132. raise RuntimeError('Cannot run JavaScript, because page is not ready.')
  133. sockets = list(jp.WebPage.sockets[self.page_id].values())
  134. results = await asyncio.gather(
  135. *[self.run_javascript_on_socket(code, socket, respond=respond, timeout=timeout, check_interval=check_interval)
  136. for socket in sockets], return_exceptions=True)
  137. return dict(zip(sockets, results))
  138. def handle_javascript_result(self, msg: AdDict) -> bool:
  139. self.waiting_javascript_commands[msg.request_id] = msg.result
  140. return False
  141. def add_head_html(self, html: str) -> None:
  142. find_parent_page().head_html += html
  143. def add_body_html(self, html: str) -> None:
  144. find_parent_page().body_html += html
  145. async def run_javascript(self, code: str, *,
  146. respond: bool = True, timeout: float = 1.0, check_interval: float = 0.01) -> Dict[WebSocket, Optional[str]]:
  147. return await find_parent_page().run_javascript(code, respond=respond, timeout=timeout, check_interval=check_interval)
  148. class page:
  149. def __init__(
  150. self,
  151. route: str,
  152. title: Optional[str] = None,
  153. *,
  154. favicon: Optional[str] = None,
  155. dark: Optional[bool] = ...,
  156. classes: str = 'q-pa-md column items-start gap-4',
  157. css: str = HtmlFormatter().get_style_defs('.codehilite'),
  158. on_connect: Optional[Callable] = None,
  159. on_page_ready: Optional[Callable] = None,
  160. on_disconnect: Optional[Callable] = None,
  161. shared: bool = False,
  162. ):
  163. """Page
  164. Creates a new page at the given route.
  165. :param route: route of the new page (path must start with '/')
  166. :param title: optional page title
  167. :param favicon: optional relative filepath to a favicon (default: `None`, NiceGUI icon will be used)
  168. :param dark: whether to use Quasar's dark mode (defaults to `dark` argument of `run` command)
  169. :param classes: tailwind classes for the container div (default: `'q-pa-md column items-start gap-4'`)
  170. :param css: CSS definitions
  171. :param on_connect: optional function or coroutine which is called for each new client connection
  172. :param on_page_ready: optional function or coroutine which is called when the websocket is connected; see `"Yielding for Page-Ready" <https://nicegui.io/reference#yielding_for_page-ready>`_ as an alternative.
  173. :param on_disconnect: optional function or coroutine which is called when a client disconnects
  174. :param shared: whether the page instance is shared between multiple clients (default: `False`)
  175. """
  176. self.route = route
  177. self.title = title
  178. self.favicon = favicon
  179. self.dark = dark
  180. self.classes = classes
  181. self.css = css
  182. self.on_connect = on_connect
  183. self.on_page_ready = on_page_ready
  184. self.on_disconnect = on_disconnect
  185. self.shared = shared
  186. self.page: Optional[Page] = None
  187. *_, self.converters = compile_path(route)
  188. def __call__(self, func: Callable, **kwargs) -> Callable:
  189. @wraps(func)
  190. async def decorated(request: Optional[Request] = None) -> Page:
  191. self.page = Page(
  192. title=self.title,
  193. favicon=self.favicon,
  194. dark=self.dark,
  195. classes=self.classes,
  196. css=self.css,
  197. on_connect=self.on_connect,
  198. on_page_ready=self.on_page_ready,
  199. on_disconnect=self.on_disconnect,
  200. shared=self.shared,
  201. )
  202. try:
  203. with Context(self.page.view):
  204. if 'request' in inspect.signature(func).parameters:
  205. if self.shared:
  206. raise RuntimeError('Cannot use `request` argument in shared page')
  207. await self.connected(request)
  208. await self.before_content()
  209. args = {**kwargs, **convert_arguments(request, self.converters, func)}
  210. result = await func(**args) if is_coroutine(func) else func(**args)
  211. if isinstance(result, types.GeneratorType):
  212. if self.shared:
  213. raise RuntimeError('Yielding for page_ready is not supported on shared pages')
  214. next(result)
  215. if isinstance(result, types.AsyncGeneratorType):
  216. if self.shared:
  217. raise RuntimeError('Yielding for page_ready is not supported on shared pages')
  218. await result.__anext__()
  219. self.page.page_ready_generator = result
  220. await self.after_content()
  221. return self.page
  222. except Exception as e:
  223. globals.log.exception(e)
  224. return error(500, str(e))
  225. builder = PageBuilder(decorated, self.shared, self.favicon)
  226. if globals.state != globals.State.STOPPED:
  227. builder.create_route(self.route)
  228. globals.page_builders[self.route] = builder
  229. return decorated
  230. async def connected(self, request: Optional[Request]) -> None:
  231. pass
  232. async def before_content(self) -> None:
  233. pass
  234. async def after_content(self) -> None:
  235. pass
  236. def find_parent_view() -> jp.HTMLBaseComponent:
  237. view_stack = get_view_stack()
  238. if not view_stack:
  239. if globals.loop and globals.loop.is_running():
  240. raise RuntimeError('cannot find parent view, view stack is empty')
  241. page = Page(shared=True)
  242. view_stack.append(page.view)
  243. jp.Route('/', page._route_function)
  244. return view_stack[-1]
  245. def find_parent_page() -> Page:
  246. pages = list(find_parent_view().pages.values())
  247. assert len(pages) == 1
  248. return pages[0]
  249. def error(status_code: int, message: Optional[str] = None) -> Page:
  250. title = globals.config.title if globals.config else f'Error {status_code}'
  251. favicon = globals.config.favicon if globals.config else None
  252. dark = globals.config.dark if globals.config else False
  253. wp = Page(title=title, favicon=favicon, dark=dark)
  254. div = jp.Div(a=wp.view, classes='w-full py-20 text-center')
  255. jp.Div(a=div, classes='text-8xl py-5', text='☹',
  256. style='font-family: "Arial Unicode MS", "Times New Roman", Times, serif;')
  257. jp.Div(a=div, classes='text-6xl py-5', text=status_code)
  258. if 400 <= status_code <= 499:
  259. title = "This page doesn't exist"
  260. elif 500 <= status_code <= 599:
  261. title = 'Server error'
  262. else:
  263. title = 'Unknown error'
  264. if message is not None:
  265. title += ':'
  266. jp.Div(a=div, classes='text-xl pt-5', text=title)
  267. jp.Div(a=div, classes='text-lg pt-2 text-gray-500', text=message or '')
  268. return wp
  269. def init_auto_index_page() -> None:
  270. view_stack: List[jp.HTMLBaseComponent] = globals.view_stacks.get(0, [])
  271. if not view_stack:
  272. return # there is no auto-index page on the view stack
  273. page: Page = view_stack.pop().pages[0]
  274. page.title = globals.config.title
  275. page.set_favicon(globals.config.favicon)
  276. page.dark = globals.config.dark
  277. page.view.classes = globals.config.main_page_classes
  278. assert len(view_stack) == 0
  279. def create_page_routes() -> None:
  280. jp.Route("/{path:path}", lambda: error(404), last=True)
  281. for route, page_builder in globals.page_builders.items():
  282. page_builder.create_route(route)
  283. def create_favicon_routes() -> None:
  284. for page_builder in globals.page_builders.values():
  285. if page_builder.favicon:
  286. add_route(None, Route(f'/static/_favicon/{page_builder.favicon}',
  287. lambda _, filepath=page_builder.favicon: FileResponse(filepath)))
  288. if globals.config.favicon:
  289. add_route(None, Route(f'/static/_favicon/{globals.config.favicon}',
  290. lambda _: FileResponse(globals.config.favicon)))