server.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. # Copyright 2021-2025 Avaiga Private Limited
  2. #
  3. # Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
  4. # the License. You may obtain a copy of the License at
  5. #
  6. # http://www.apache.org/licenses/LICENSE-2.0
  7. #
  8. # Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
  9. # an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
  10. # specific language governing permissions and limitations under the License.
  11. import os
  12. import pathlib
  13. import threading
  14. import time
  15. import typing as t
  16. import webbrowser
  17. from contextlib import contextmanager
  18. import socketio
  19. import uvicorn
  20. from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request
  21. from fastapi.encoders import jsonable_encoder
  22. from fastapi.middleware.cors import CORSMiddleware
  23. from fastapi.responses import HTMLResponse, JSONResponse
  24. from fastapi.templating import Jinja2Templates
  25. from flask.ctx import _AppCtxGlobals
  26. from starlette.middleware.base import BaseHTTPMiddleware
  27. import __main__
  28. from taipy.common.logger._taipy_logger import _TaipyLogger
  29. from ..._renderers.json import _TaipyJsonEncoder
  30. from ...config import ServerConfig
  31. from ...custom._page import _ExternalResourceHandlerManager
  32. from ...utils import _is_in_notebook, _is_port_open, _RuntimeManager
  33. from ..server import _Server
  34. from .request import request as request_context
  35. from .request import request_meta
  36. from .request import sid as sid_context
  37. from .utils import exec_async, send_from_directory
  38. if t.TYPE_CHECKING:
  39. from ...gui import Gui
  40. # this is for fastapi routes
  41. async def request_meta_dependency(_: Request):
  42. new_request_meta = _AppCtxGlobals()
  43. request_meta.set(new_request_meta)
  44. return new_request_meta
  45. async def request_dependency(request: Request):
  46. request_context.set(request)
  47. return request # Still return it if needed in the route
  48. # this is for ws calls as contextmanager
  49. @contextmanager
  50. def get_request_meta_ctx():
  51. new_request_meta = _AppCtxGlobals()
  52. request_meta.set(new_request_meta)
  53. yield new_request_meta
  54. request_meta.set(None)
  55. @contextmanager
  56. def set_request_ctx(request: Request):
  57. request_context.set(request)
  58. yield
  59. request_context.set(None)
  60. @contextmanager
  61. def set_sid_ctx(sid: str):
  62. sid_context.set(sid)
  63. yield
  64. sid_context.set(None)
  65. class CleanupMiddleware(BaseHTTPMiddleware):
  66. async def dispatch(self, request: Request, call_next):
  67. response = await call_next(request)
  68. request_context.set(None)
  69. request_meta.set(None)
  70. return response
  71. class FastAPIServer(_Server):
  72. def __init__(
  73. self,
  74. gui: "Gui",
  75. server: t.Optional[FastAPI] = None,
  76. path_mapping: t.Optional[dict] = None,
  77. # async mode is not used in FastAPI but it is here to comply with the api for now
  78. async_mode: t.Optional[str] = None,
  79. allow_upgrades: bool = True,
  80. server_config: t.Optional[ServerConfig] = None,
  81. ):
  82. self._gui = gui
  83. server_config = server_config or {}
  84. self._path_mapping = path_mapping or {}
  85. self._allow_upgrades = allow_upgrades
  86. self._is_running = False
  87. self._port: t.Optional[int] = None
  88. # server setup
  89. self._server = server or FastAPI(json_encoder=_TaipyJsonEncoder)
  90. self._ws = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*")
  91. self._server.mount("/socket.io", socketio.ASGIApp(self._ws, other_asgi_app=self._server, socketio_path="/"))
  92. # registering middleware
  93. self._server.add_middleware(CleanupMiddleware)
  94. self._server.add_middleware(
  95. CORSMiddleware,
  96. allow_origins=["*"],
  97. allow_credentials=True,
  98. allow_methods=["*"],
  99. allow_headers=["*"],
  100. )
  101. # Define your event handlers and routes
  102. @self._ws.event
  103. async def connect(sid, *args):
  104. with get_request_meta_ctx(), set_sid_ctx(sid):
  105. gui._handle_connect()
  106. @self._ws.event
  107. async def message(sid, *args):
  108. message = args[0]
  109. with get_request_meta_ctx(), set_sid_ctx(sid):
  110. if "status" in message:
  111. _TaipyLogger._get_logger().info(message["status"])
  112. elif "type" in message:
  113. gui._manage_ws_message(message["type"], message)
  114. @self._ws.event
  115. async def disconnect(sid):
  116. with get_request_meta_ctx(), set_sid_ctx(sid):
  117. gui._handle_disconnect()
  118. def _get_default_handler(
  119. self,
  120. static_folder: str,
  121. template_folder: str,
  122. title: str,
  123. favicon: str,
  124. root_margin: str,
  125. scripts: t.List[str],
  126. styles: t.List[str],
  127. version: str,
  128. client_config: t.Dict[str, t.Any],
  129. watermark: t.Optional[str],
  130. css_vars: str,
  131. base_url: str,
  132. ) -> APIRouter:
  133. templates = Jinja2Templates(directory=template_folder)
  134. taipy_router = APIRouter()
  135. @taipy_router.get("/", response_class=HTMLResponse)
  136. @taipy_router.get("/{path:path}", response_class=HTMLResponse)
  137. def my_index(request: Request, path: str = "", request_meta: _AppCtxGlobals = Depends(request_meta_dependency)): # noqa: B008
  138. with set_request_ctx(request), get_request_meta_ctx():
  139. resource_handler_id = request.cookies.get("_RESOURCE_HANDLER_ARG", None)
  140. if resource_handler_id is not None:
  141. resource_handler = _ExternalResourceHandlerManager().get(resource_handler_id)
  142. if resource_handler is None:
  143. reload_html = """
  144. <html>
  145. <head><style>body {background-color: black; margin: 0;}</style></head>
  146. <body><script>location.reload();</script></body>
  147. </html>
  148. """
  149. response = HTMLResponse(content=reload_html, status_code=400)
  150. response.set_cookie(
  151. "_RESOURCE_HANDLER_ARG",
  152. "",
  153. secure=request.url.scheme == "https",
  154. httponly=True,
  155. expires=0,
  156. path="/",
  157. )
  158. return response
  159. try:
  160. return resource_handler.get_resources(path, static_folder, base_url)
  161. except Exception as e:
  162. raise HTTPException(
  163. status_code=500, detail="Can't get resources from custom resource handler"
  164. ) from e
  165. if not path or path == "index.html" or "." not in path:
  166. try:
  167. return templates.TemplateResponse(
  168. request,
  169. "index.html",
  170. {
  171. "title": title,
  172. "favicon": f"{favicon}?version={version}",
  173. "root_margin": root_margin,
  174. "watermark": watermark,
  175. "config": client_config,
  176. "scripts": scripts,
  177. "styles": styles,
  178. "version": version,
  179. "css_vars": css_vars,
  180. "base_url": base_url,
  181. },
  182. )
  183. except Exception:
  184. raise HTTPException( # noqa: B904
  185. status_code=500,
  186. detail="Something is wrong with the taipy-gui front-end installation. Check that the js bundle has been properly built.", # noqa: E501
  187. )
  188. if path == "taipy.status.json":
  189. return JSONResponse(content=self._gui._serve_status(pathlib.Path(template_folder) / path))
  190. if (file_path := str(os.path.normpath((base_path := static_folder + os.path.sep) + path))).startswith(
  191. base_path
  192. ) and os.path.isfile(file_path):
  193. return send_from_directory(base_path, path)
  194. # use the path mapping to detect and find resources
  195. for k, v in self._path_mapping.items():
  196. if (
  197. path.startswith(f"{k}/")
  198. and (
  199. file_path := str(os.path.normpath((base_path := v + os.path.sep) + path[len(k) + 1 :]))
  200. ).startswith(base_path)
  201. and os.path.isfile(file_path)
  202. ):
  203. return send_from_directory(base_path, path[len(k) + 1 :])
  204. if (
  205. hasattr(__main__, "__file__")
  206. and (
  207. file_path := str(
  208. os.path.normpath((base_path := os.path.dirname(__main__.__file__) + os.path.sep) + path)
  209. )
  210. ).startswith(base_path)
  211. and os.path.isfile(file_path)
  212. and not self._is_ignored(file_path)
  213. ):
  214. return send_from_directory(base_path, path)
  215. if (
  216. (
  217. file_path := str(os.path.normpath((base_path := self._gui._root_dir + os.path.sep) + path)) # type: ignore[attr-defined]
  218. ).startswith(base_path)
  219. and os.path.isfile(file_path)
  220. and not self._is_ignored(file_path)
  221. ):
  222. return send_from_directory(base_path, path)
  223. # Default error return for unmatched paths
  224. raise HTTPException(status_code=404, detail="")
  225. return taipy_router
  226. def direct_render_json(self, data):
  227. return jsonable_encoder(data)
  228. # return _TaipyJsonAdapter().parse(data)
  229. def get_server_instance(self):
  230. return self._server
  231. def get_port(self) -> int:
  232. return self._port or -1
  233. def send_ws_message(self, *args, **kwargs):
  234. if isinstance(kwargs["to"], str) or kwargs["to"] is None:
  235. kwargs["to"] = [kwargs["to"]]
  236. for sid in kwargs["to"]:
  237. temp_kwargs = kwargs.copy()
  238. temp_kwargs["to"] = sid
  239. exec_async(self._ws.emit, "message", *args, **temp_kwargs)
  240. def run(
  241. self,
  242. host,
  243. port,
  244. client_url,
  245. debug,
  246. use_reloader,
  247. server_log,
  248. run_in_thread,
  249. allow_unsafe_werkzeug,
  250. notebook_proxy,
  251. port_auto_ranges,
  252. ):
  253. from ..utils import is_running_from_reloader
  254. host_value = host if host != "0.0.0.0" else "localhost"
  255. self._host = host
  256. if port == "auto":
  257. port = self._get_random_port(port_auto_ranges)
  258. server_url = f"http://{host_value}:{port}"
  259. self._port = port
  260. if _is_in_notebook() or run_in_thread:
  261. runtime_manager = _RuntimeManager()
  262. runtime_manager.add_gui(self._gui, port)
  263. if debug and not is_running_from_reloader() and _is_port_open(host_value, port):
  264. raise ConnectionError(
  265. f"Port {port} is already opened on {host} because another application is running on the same port.\nPlease pick another port number and rerun with the 'port=<new_port>' setting.\nYou can also let Taipy choose a port number for you by running with the 'port=\"auto\"' setting." # noqa: E501
  266. )
  267. if not is_running_from_reloader() and self._gui._get_config("run_browser", False): # type: ignore[attr-defined]
  268. webbrowser.open(client_url or server_url, new=2)
  269. if _is_in_notebook() or run_in_thread:
  270. return self._run_notebook()
  271. self._is_running = True
  272. run_config = {
  273. "app": self._server,
  274. "host": host,
  275. "port": port,
  276. "reload": use_reloader,
  277. }
  278. uvicorn.run(**run_config)
  279. def _run_notebook(self):
  280. if not self._port or not self._host:
  281. raise RuntimeError("Port and host must be set before running the server in notebook mode.")
  282. self._is_running = True
  283. config = uvicorn.Config(app=self._server, host=self._host, port=self._port, reload=False, log_level="info")
  284. self._thread_server = uvicorn.Server(config)
  285. self._thread = threading.Thread(target=self._thread_server.run, daemon=True)
  286. self._thread.start()
  287. return
  288. def is_running(self):
  289. return self._is_running
  290. def stop_thread(self):
  291. if hasattr(self, "_thread") and self._thread.is_alive() and self._is_running:
  292. self._thread_server.should_exit = True
  293. self._thread.join()
  294. while _is_port_open(self._host, self._port):
  295. time.sleep(0.1)
  296. self._is_running = False