app.py 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. import inspect
  2. from enum import Enum
  3. from pathlib import Path
  4. from typing import Any, Awaitable, Callable, List, Optional, Union
  5. from fastapi import FastAPI, HTTPException, Request
  6. from fastapi.responses import FileResponse, StreamingResponse
  7. from fastapi.staticfiles import StaticFiles
  8. from . import background_tasks, globals, helpers # pylint: disable=redefined-builtin
  9. from .logging import log
  10. from .native import Native
  11. from .observables import ObservableSet
  12. from .storage import Storage
  13. class State(Enum):
  14. STOPPED = 0
  15. STARTING = 1
  16. STARTED = 2
  17. STOPPING = 3
  18. class App(FastAPI):
  19. def __init__(self, **kwargs) -> None:
  20. super().__init__(**kwargs)
  21. self.native = Native()
  22. self.storage = Storage()
  23. self.urls = ObservableSet()
  24. self.state: State = State.STOPPED
  25. self._startup_handlers: List[Union[Callable[..., Any], Awaitable]] = []
  26. self._shutdown_handlers: List[Union[Callable[..., Any], Awaitable]] = []
  27. self._connect_handlers: List[Union[Callable[..., Any], Awaitable]] = []
  28. self._disconnect_handlers: List[Union[Callable[..., Any], Awaitable]] = []
  29. self._exception_handlers: List[Callable[..., Any]] = [log.exception]
  30. @property
  31. def is_starting(self) -> bool:
  32. """Return whether NiceGUI is starting."""
  33. return self.state == State.STARTING
  34. @property
  35. def is_started(self) -> bool:
  36. """Return whether NiceGUI is started."""
  37. return self.state == State.STARTED
  38. @property
  39. def is_stopping(self) -> bool:
  40. """Return whether NiceGUI is stopping."""
  41. return self.state == State.STOPPING
  42. @property
  43. def is_stopped(self) -> bool:
  44. """Return whether NiceGUI is stopped."""
  45. return self.state == State.STOPPED
  46. def start(self) -> None:
  47. """Start NiceGUI. (For internal use only.)"""
  48. self.state = State.STARTING
  49. with globals.index_client:
  50. for t in self._startup_handlers:
  51. helpers.safe_invoke(t)
  52. self.state = State.STARTED
  53. def stop(self) -> None:
  54. """Stop NiceGUI. (For internal use only.)"""
  55. self.state = State.STOPPING
  56. with globals.index_client:
  57. for t in self._shutdown_handlers:
  58. helpers.safe_invoke(t)
  59. self.state = State.STOPPED
  60. def on_connect(self, handler: Union[Callable, Awaitable]) -> None:
  61. """Called every time a new client connects to NiceGUI.
  62. The callback has an optional parameter of `nicegui.Client`.
  63. """
  64. self._connect_handlers.append(handler)
  65. def on_disconnect(self, handler: Union[Callable, Awaitable]) -> None:
  66. """Called every time a new client disconnects from NiceGUI.
  67. The callback has an optional parameter of `nicegui.Client`.
  68. """
  69. self._disconnect_handlers.append(handler)
  70. def on_startup(self, handler: Union[Callable, Awaitable]) -> None:
  71. """Called when NiceGUI is started or restarted.
  72. Needs to be called before `ui.run()`.
  73. """
  74. if self.is_started:
  75. raise RuntimeError('Unable to register another startup handler. NiceGUI has already been started.')
  76. self._startup_handlers.append(handler)
  77. def on_shutdown(self, handler: Union[Callable, Awaitable]) -> None:
  78. """Called when NiceGUI is shut down or restarted.
  79. When NiceGUI is shut down or restarted, all tasks still in execution will be automatically canceled.
  80. """
  81. self._shutdown_handlers.append(handler)
  82. def on_exception(self, handler: Callable) -> None:
  83. """Called when an exception occurs.
  84. The callback has an optional parameter of `Exception`.
  85. """
  86. self._exception_handlers.append(handler)
  87. def handle_exception(self, exception: Exception) -> None:
  88. """Handle an exception by invoking all registered exception handlers."""
  89. for handler in self._exception_handlers:
  90. result = handler() if not inspect.signature(handler).parameters else handler(exception)
  91. if helpers.is_coroutine_function(handler):
  92. background_tasks.create(result)
  93. def shutdown(self) -> None:
  94. """Shut down NiceGUI.
  95. This will programmatically stop the server.
  96. Only possible when auto-reload is disabled.
  97. """
  98. if globals.reload:
  99. raise RuntimeError('calling shutdown() is not supported when auto-reload is enabled')
  100. if self.native.main_window:
  101. self.native.main_window.destroy()
  102. else:
  103. globals.server.should_exit = True
  104. def add_static_files(self, url_path: str, local_directory: Union[str, Path]) -> None:
  105. """Add a directory of static files.
  106. `add_static_files()` makes a local directory available at the specified endpoint, e.g. `'/static'`.
  107. This is useful for providing local data like images to the frontend.
  108. Otherwise the browser would not be able to access the files.
  109. Do only put non-security-critical files in there, as they are accessible to everyone.
  110. To make a single file accessible, you can use `add_static_file()`.
  111. For media files which should be streamed, you can use `add_media_files()` or `add_media_file()` instead.
  112. :param url_path: string that starts with a slash "/" and identifies the path at which the files should be served
  113. :param local_directory: local folder with files to serve as static content
  114. """
  115. if url_path == '/':
  116. raise ValueError('''Path cannot be "/", because it would hide NiceGUI's internal "/_nicegui" route.''')
  117. self.mount(url_path, StaticFiles(directory=str(local_directory)))
  118. def add_static_file(self, *,
  119. local_file: Union[str, Path],
  120. url_path: Optional[str] = None,
  121. single_use: bool = False,
  122. ) -> str:
  123. """Add a single static file.
  124. Allows a local file to be accessed online with enabled caching.
  125. If `url_path` is not specified, a path will be generated.
  126. To make a whole folder of files accessible, use `add_static_files()` instead.
  127. For media files which should be streamed, you can use `add_media_files()` or `add_media_file()` instead.
  128. :param local_file: local file to serve as static content
  129. :param url_path: string that starts with a slash "/" and identifies the path at which the file should be served (default: None -> auto-generated URL path)
  130. :param single_use: whether to remove the route after the file has been downloaded once (default: False)
  131. :return: URL path which can be used to access the file
  132. """
  133. file = Path(local_file).resolve()
  134. if not file.is_file():
  135. raise ValueError(f'File not found: {file}')
  136. path = f'/_nicegui/auto/static/{helpers.hash_file_path(file)}/{file.name}' if url_path is None else url_path
  137. @self.get(path)
  138. def read_item() -> FileResponse:
  139. if single_use:
  140. self.remove_route(path)
  141. return FileResponse(file, headers={'Cache-Control': 'public, max-age=3600'})
  142. return path
  143. def add_media_files(self, url_path: str, local_directory: Union[str, Path]) -> None:
  144. """Add directory of media files.
  145. `add_media_files()` allows a local files to be streamed from a specified endpoint, e.g. `'/media'`.
  146. This should be used for media files to support proper streaming.
  147. Otherwise the browser would not be able to access and load the the files incrementally or jump to different positions in the stream.
  148. Do only put non-security-critical files in there, as they are accessible to everyone.
  149. To make a single file accessible via streaming, you can use `add_media_file()`.
  150. For small static files, you can use `add_static_files()` or `add_static_file()` instead.
  151. :param url_path: string that starts with a slash "/" and identifies the path at which the files should be served
  152. :param local_directory: local folder with files to serve as media content
  153. """
  154. @self.get(url_path + '/{filename:path}')
  155. def read_item(request: Request, filename: str) -> StreamingResponse:
  156. filepath = Path(local_directory) / filename
  157. if not filepath.is_file():
  158. raise HTTPException(status_code=404, detail='Not Found')
  159. return helpers.get_streaming_response(filepath, request)
  160. def add_media_file(self, *,
  161. local_file: Union[str, Path],
  162. url_path: Optional[str] = None,
  163. single_use: bool = False,
  164. ) -> str:
  165. """Add a single media file.
  166. Allows a local file to be streamed.
  167. If `url_path` is not specified, a path will be generated.
  168. To make a whole folder of media files accessible via streaming, use `add_media_files()` instead.
  169. For small static files, you can use `add_static_files()` or `add_static_file()` instead.
  170. :param local_file: local file to serve as media content
  171. :param url_path: string that starts with a slash "/" and identifies the path at which the file should be served (default: None -> auto-generated URL path)
  172. :param single_use: whether to remove the route after the media file has been downloaded once (default: False)
  173. :return: URL path which can be used to access the file
  174. """
  175. file = Path(local_file).resolve()
  176. if not file.is_file():
  177. raise ValueError(f'File not found: {local_file}')
  178. path = f'/_nicegui/auto/media/{helpers.hash_file_path(file)}/{file.name}' if url_path is None else url_path
  179. @self.get(path)
  180. def read_item(request: Request) -> StreamingResponse:
  181. if single_use:
  182. self.remove_route(path)
  183. return helpers.get_streaming_response(file, request)
  184. return path
  185. def remove_route(self, path: str) -> None:
  186. """Remove routes with the given path."""
  187. self.routes[:] = [r for r in self.routes if getattr(r, 'path', None) != path]