client.py 17 KB

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