client.py 16 KB

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