client.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. from __future__ import annotations
  2. import asyncio
  3. import inspect
  4. import time
  5. import uuid
  6. from contextlib import contextmanager
  7. from pathlib import Path
  8. from typing import TYPE_CHECKING, Any, Awaitable, Callable, ClassVar, Dict, Iterable, Iterator, List, Optional, Union
  9. from fastapi import Request
  10. from fastapi.responses import Response
  11. from fastapi.templating import Jinja2Templates
  12. from typing_extensions import Self
  13. from . import background_tasks, binding, core, helpers, json, storage
  14. from .awaitable_response import AwaitableResponse
  15. from .dependencies import generate_resources
  16. from .element import Element
  17. from .favicon import get_favicon_url
  18. from .javascript_request import JavaScriptRequest
  19. from .logging import log
  20. from .observables import ObservableDict
  21. from .outbox import Outbox
  22. from .version import __version__
  23. if TYPE_CHECKING:
  24. from .page import page
  25. templates = Jinja2Templates(Path(__file__).parent / 'templates')
  26. class Client:
  27. page_routes: ClassVar[Dict[Callable[..., Any], str]] = {}
  28. """Maps page builders to their routes."""
  29. instances: ClassVar[Dict[str, Client]] = {}
  30. """Maps client IDs to clients."""
  31. auto_index_client: Client
  32. """The client that is used to render the auto-index page."""
  33. shared_head_html = ''
  34. """HTML to be inserted in the <head> of every page template."""
  35. shared_body_html = ''
  36. """HTML to be inserted in the <body> of every page template."""
  37. def __init__(self, page: page, *, request: Optional[Request]) -> None:
  38. self.request: Optional[Request] = request
  39. self.id = str(uuid.uuid4())
  40. self.created = time.time()
  41. self.instances[self.id] = self
  42. self.elements: Dict[int, Element] = {}
  43. self.next_element_id: int = 0
  44. self.is_waiting_for_connection: bool = False
  45. self.is_waiting_for_disconnect: bool = False
  46. self.environ: Optional[Dict[str, Any]] = None
  47. self.shared = request is None
  48. self.on_air = False
  49. self._disconnect_task: Optional[asyncio.Task] = None
  50. self._deleted = False
  51. self.tab_id: Optional[str] = None
  52. self.outbox = Outbox(self)
  53. with Element('q-layout', _client=self).props('view="hhh lpr fff"').classes('nicegui-layout') as self.layout:
  54. with Element('q-page-container') as self.page_container:
  55. with Element('q-page'):
  56. self.content = Element('div').classes('nicegui-content')
  57. self.title: Optional[str] = None
  58. self._head_html = ''
  59. self._body_html = ''
  60. self.page = page
  61. self.storage = ObservableDict()
  62. self.connect_handlers: List[Union[Callable[..., Any], Awaitable]] = []
  63. self.disconnect_handlers: List[Union[Callable[..., Any], Awaitable]] = []
  64. self._temporary_socket_id: Optional[str] = None
  65. @property
  66. def is_auto_index_client(self) -> bool:
  67. """Return True if this client is the auto-index client."""
  68. return self is self.auto_index_client
  69. @property
  70. def ip(self) -> Optional[str]:
  71. """Return the IP address of the client, or None if the client is not connected."""
  72. return self.environ['asgi.scope']['client'][0] if self.environ else None # pylint: disable=unsubscriptable-object
  73. @property
  74. def has_socket_connection(self) -> bool:
  75. """Return True if the client is connected, False otherwise."""
  76. return self.tab_id is not None
  77. @property
  78. def head_html(self) -> str:
  79. """Return the HTML code to be inserted in the <head> of the page template."""
  80. return self.shared_head_html + self._head_html
  81. @property
  82. def body_html(self) -> str:
  83. """Return the HTML code to be inserted in the <body> of the page template."""
  84. return self.shared_body_html + self._body_html
  85. def __enter__(self) -> Self:
  86. self.content.__enter__()
  87. return self
  88. def __exit__(self, *_) -> None:
  89. self.content.__exit__()
  90. def build_response(self, request: Request, status_code: int = 200) -> Response:
  91. """Build a FastAPI response for the client."""
  92. self.outbox.updates.clear()
  93. prefix = request.headers.get('X-Forwarded-Prefix', request.scope.get('root_path', ''))
  94. elements = json.dumps({
  95. id: element._to_dict() for id, element in self.elements.items() # pylint: disable=protected-access
  96. })
  97. socket_io_js_query_params = {**core.app.config.socket_io_js_query_params, 'client_id': self.id}
  98. vue_html, vue_styles, vue_scripts, imports, js_imports = generate_resources(prefix, self.elements.values())
  99. return templates.TemplateResponse(
  100. request=request,
  101. name='index.html',
  102. context={
  103. 'request': request,
  104. 'version': __version__,
  105. 'elements': elements.replace('&', '&amp;')
  106. .replace('<', '&lt;')
  107. .replace('>', '&gt;')
  108. .replace('`', '&#96;')
  109. .replace('$', '&#36;'),
  110. 'head_html': self.head_html,
  111. 'body_html': '<style>' + '\n'.join(vue_styles) + '</style>\n' + self.body_html + '\n' + '\n'.join(vue_html),
  112. 'vue_scripts': '\n'.join(vue_scripts),
  113. 'imports': json.dumps(imports),
  114. 'js_imports': '\n'.join(js_imports),
  115. 'quasar_config': json.dumps(core.app.config.quasar_config),
  116. 'title': self.page.resolve_title() if self.title is None else self.title,
  117. 'viewport': self.page.resolve_viewport(),
  118. 'favicon_url': get_favicon_url(self.page, prefix),
  119. 'dark': str(self.page.resolve_dark()),
  120. 'language': self.page.resolve_language(),
  121. 'prefix': prefix,
  122. 'tailwind': core.app.config.tailwind,
  123. 'prod_js': core.app.config.prod_js,
  124. 'socket_io_js_query_params': socket_io_js_query_params,
  125. 'socket_io_js_extra_headers': core.app.config.socket_io_js_extra_headers,
  126. 'socket_io_js_transports': core.app.config.socket_io_js_transports,
  127. },
  128. status_code=status_code,
  129. headers={'Cache-Control': 'no-store', 'X-NiceGUI-Content': 'page'},
  130. )
  131. async def connected(self, timeout: float = 3.0, check_interval: float = 0.1) -> None:
  132. """Block execution until the client is connected."""
  133. self.is_waiting_for_connection = True
  134. deadline = time.time() + timeout
  135. while not self.has_socket_connection:
  136. if time.time() > deadline:
  137. raise TimeoutError(f'No connection after {timeout} seconds')
  138. await asyncio.sleep(check_interval)
  139. self.is_waiting_for_connection = False
  140. async def disconnected(self, check_interval: float = 0.1) -> None:
  141. """Block execution until the client disconnects."""
  142. if not self.has_socket_connection:
  143. await self.connected()
  144. self.is_waiting_for_disconnect = True
  145. while self.id in self.instances:
  146. await asyncio.sleep(check_interval)
  147. self.is_waiting_for_disconnect = False
  148. def run_javascript(self, code: str, *,
  149. respond: Optional[bool] = None, # DEPRECATED
  150. timeout: float = 1.0,
  151. check_interval: float = 0.01, # DEPRECATED
  152. ) -> AwaitableResponse:
  153. """Execute JavaScript on the client.
  154. The client connection must be established before this method is called.
  155. You can do this by `await client.connected()` or register a callback with `client.on_connect(...)`.
  156. If the function is awaited, the result of the JavaScript code is returned.
  157. Otherwise, the JavaScript code is executed without waiting for a response.
  158. :param code: JavaScript code to run
  159. :param timeout: timeout in seconds (default: `1.0`)
  160. :return: AwaitableResponse that can be awaited to get the result of the JavaScript code
  161. """
  162. if respond is True:
  163. helpers.warn_once('The "respond" argument of run_javascript() has been removed. '
  164. 'Now the method always returns an AwaitableResponse that can be awaited. '
  165. 'Please remove the "respond=True" argument.')
  166. if respond is False:
  167. raise ValueError('The "respond" argument of run_javascript() has been removed. '
  168. 'Now the method always returns an AwaitableResponse that can be awaited. '
  169. 'Please remove the "respond=False" argument and call the method without awaiting.')
  170. if check_interval != 0.01:
  171. helpers.warn_once('The "check_interval" argument of run_javascript() and similar methods has been removed. '
  172. 'Now the method automatically returns when receiving a response without checking regularly in an interval. '
  173. 'Please remove the "check_interval" argument.')
  174. request_id = str(uuid.uuid4())
  175. target_id = self._temporary_socket_id or self.id
  176. def send_and_forget():
  177. self.outbox.enqueue_message('run_javascript', {'code': code}, target_id)
  178. async def send_and_wait():
  179. if self is self.auto_index_client:
  180. raise RuntimeError('Cannot await JavaScript responses on the auto-index page. '
  181. 'There could be multiple clients connected and it is not clear which one to wait for.')
  182. self.outbox.enqueue_message('run_javascript', {'code': code, 'request_id': request_id}, target_id)
  183. return await JavaScriptRequest(request_id, timeout=timeout)
  184. return AwaitableResponse(send_and_forget, send_and_wait)
  185. def open(self, target: Union[Callable[..., Any], str], new_tab: bool = False) -> None:
  186. """Open a new page in the client."""
  187. path = target if isinstance(target, str) else self.page_routes[target]
  188. self.outbox.enqueue_message('open', {'path': path, 'new_tab': new_tab}, self.id)
  189. def download(self, src: Union[str, bytes], filename: Optional[str] = None, media_type: str = '') -> None:
  190. """Download a file from a given URL or raw bytes."""
  191. self.outbox.enqueue_message('download', {'src': src, 'filename': filename, 'media_type': media_type}, self.id)
  192. def on_connect(self, handler: Union[Callable[..., Any], Awaitable]) -> None:
  193. """Add a callback to be invoked when the client connects."""
  194. self.connect_handlers.append(handler)
  195. def on_disconnect(self, handler: Union[Callable[..., Any], Awaitable]) -> None:
  196. """Add a callback to be invoked when the client disconnects."""
  197. self.disconnect_handlers.append(handler)
  198. def handle_handshake(self) -> None:
  199. """Cancel pending disconnect task and invoke connect handlers."""
  200. if self._disconnect_task:
  201. self._disconnect_task.cancel()
  202. self._disconnect_task = None
  203. storage.request_contextvar.set(self.request)
  204. for t in self.connect_handlers:
  205. self.safe_invoke(t)
  206. for t in core.app._connect_handlers: # pylint: disable=protected-access
  207. self.safe_invoke(t)
  208. def handle_disconnect(self) -> None:
  209. """Wait for the browser to reconnect; invoke disconnect handlers if it doesn't."""
  210. async def handle_disconnect() -> None:
  211. if self.page.reconnect_timeout is not None:
  212. delay = self.page.reconnect_timeout
  213. else:
  214. delay = core.app.config.reconnect_timeout # pylint: disable=protected-access
  215. await asyncio.sleep(delay)
  216. for t in self.disconnect_handlers:
  217. self.safe_invoke(t)
  218. for t in core.app._disconnect_handlers: # pylint: disable=protected-access
  219. self.safe_invoke(t)
  220. if not self.shared:
  221. self.delete()
  222. self._disconnect_task = background_tasks.create(handle_disconnect())
  223. def handle_event(self, msg: Dict) -> None:
  224. """Forward an event to the corresponding element."""
  225. with self:
  226. sender = self.elements.get(msg['id'])
  227. if sender is not None:
  228. msg['args'] = [None if arg is None else json.loads(arg) for arg in msg.get('args', [])]
  229. if len(msg['args']) == 1:
  230. msg['args'] = msg['args'][0]
  231. sender._handle_event(msg) # pylint: disable=protected-access
  232. def handle_javascript_response(self, msg: Dict) -> None:
  233. """Store the result of a JavaScript command."""
  234. JavaScriptRequest.resolve(msg['request_id'], msg['result'])
  235. def safe_invoke(self, func: Union[Callable[..., Any], Awaitable]) -> None:
  236. """Invoke the potentially async function in the client context and catch any exceptions."""
  237. try:
  238. if isinstance(func, Awaitable):
  239. async def func_with_client():
  240. with self:
  241. await func
  242. background_tasks.create(func_with_client())
  243. else:
  244. with self:
  245. result = func(self) if len(inspect.signature(func).parameters) == 1 else func()
  246. if helpers.is_coroutine_function(func):
  247. async def result_with_client():
  248. with self:
  249. await result
  250. background_tasks.create(result_with_client())
  251. except Exception as e:
  252. core.app.handle_exception(e)
  253. def remove_elements(self, elements: Iterable[Element]) -> None:
  254. """Remove the given elements from the client."""
  255. binding.remove(elements)
  256. element_ids = [element.id for element in elements]
  257. for element in elements:
  258. element._handle_delete() # pylint: disable=protected-access
  259. element._deleted = True # pylint: disable=protected-access
  260. self.outbox.enqueue_delete(element)
  261. for element_id in element_ids:
  262. del self.elements[element_id]
  263. def remove_all_elements(self) -> None:
  264. """Remove all elements from the client."""
  265. self.remove_elements(self.elements.values())
  266. def delete(self) -> None:
  267. """Delete a client and all its elements.
  268. If the global clients dictionary does not contain the client, its elements are still removed and a KeyError is raised.
  269. Normally this should never happen, but has been observed (see #1826).
  270. """
  271. self.remove_all_elements()
  272. self.outbox.stop()
  273. del Client.instances[self.id]
  274. self._deleted = True
  275. def check_existence(self) -> None:
  276. """Check if the client still exists and print a warning if it doesn't."""
  277. if self._deleted:
  278. helpers.warn_once('Client has been deleted but is still being used. '
  279. 'This is most likely a bug in your application code. '
  280. 'See https://github.com/zauberzeug/nicegui/issues/3028 for more information.',
  281. stack_info=True)
  282. @contextmanager
  283. def individual_target(self, socket_id: str) -> Iterator[None]:
  284. """Use individual socket ID while in this context.
  285. This context is useful for limiting messages from the shared auto-index page to a single client.
  286. """
  287. self._temporary_socket_id = socket_id
  288. yield
  289. self._temporary_socket_id = None
  290. @classmethod
  291. async def prune_instances(cls) -> None:
  292. """Prune stale clients in an endless loop."""
  293. while True:
  294. try:
  295. stale_clients = [
  296. client
  297. for client in cls.instances.values()
  298. if not client.shared and not client.has_socket_connection and client.created < time.time() - 60.0
  299. ]
  300. for client in stale_clients:
  301. client.delete()
  302. except Exception:
  303. # NOTE: make sure the loop doesn't crash
  304. log.exception('Error while pruning clients')
  305. await asyncio.sleep(10)