server.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  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. from __future__ import annotations
  12. import contextlib
  13. import logging
  14. import os
  15. import pathlib
  16. import sys
  17. import time
  18. import typing as t
  19. import webbrowser
  20. from contextlib import contextmanager
  21. from importlib import util
  22. from flask import (
  23. Blueprint,
  24. Flask,
  25. json,
  26. jsonify,
  27. make_response,
  28. render_template,
  29. render_template_string,
  30. request,
  31. send_from_directory,
  32. )
  33. from flask_cors import CORS
  34. from flask_socketio import SocketIO
  35. from kthread import KThread
  36. from werkzeug.serving import is_running_from_reloader
  37. import __main__
  38. from taipy.common.logger._taipy_logger import _TaipyLogger
  39. from ..._renderers.json import _TaipyJsonProvider
  40. from ...config import ServerConfig
  41. from ...custom._page import _ExternalResourceHandlerManager
  42. from ...utils import _is_in_notebook, _is_port_open, _RuntimeManager
  43. from ..server import _Server
  44. if t.TYPE_CHECKING:
  45. from ...gui import Gui
  46. class FlaskServer(_Server):
  47. def __init__(
  48. self,
  49. gui: Gui,
  50. server: t.Optional[Flask] = None,
  51. path_mapping: t.Optional[dict] = None,
  52. async_mode: t.Optional[str] = None,
  53. allow_upgrades: bool = True,
  54. server_config: t.Optional[ServerConfig] = None,
  55. ):
  56. self._gui = gui
  57. server_config = server_config or {}
  58. self._server = server
  59. if self._server is None:
  60. flask_config: t.Dict[str, t.Any] = {"import_name": "Taipy"}
  61. if "flask" in server_config and isinstance(server_config["flask"], dict):
  62. flask_config.update(server_config["flask"])
  63. self._server = Flask(**flask_config)
  64. if "SECRET_KEY" not in self._server.config or not self._server.config["SECRET_KEY"]:
  65. self._server.config["SECRET_KEY"] = "TaIpY"
  66. # setup cors
  67. if "cors" not in server_config or (
  68. "cors" in server_config and (isinstance(server_config["cors"], dict) or server_config["cors"] is True)
  69. ):
  70. cors_config = (
  71. server_config["cors"] if "cors" in server_config and isinstance(server_config["cors"], dict) else {}
  72. )
  73. CORS(self._server, **cors_config)
  74. # setup socketio
  75. socketio_config: t.Dict[str, t.Any] = {
  76. "cors_allowed_origins": "*",
  77. "ping_timeout": 10,
  78. "ping_interval": 5,
  79. "json": json,
  80. "async_mode": async_mode,
  81. "allow_upgrades": allow_upgrades,
  82. }
  83. if "socketio" in server_config and isinstance(server_config["socketio"], dict):
  84. socketio_config.update(server_config["socketio"])
  85. self._ws = SocketIO(self._server, **socketio_config)
  86. self._apply_patch()
  87. # set json encoder (for Taipy specific types)
  88. self._server.json_provider_class = _TaipyJsonProvider
  89. self._server.json = self._server.json_provider_class(self._server)
  90. self.__path_mapping = path_mapping or {}
  91. self.__ssl_context = server_config.get("ssl_context", None)
  92. self._is_running = False
  93. # Websocket (handle json message)
  94. # adding args for the one call with a server ack request
  95. @self._ws.on("message")
  96. def handle_message(message, *args) -> None:
  97. if "status" in message:
  98. _TaipyLogger._get_logger().info(message["status"])
  99. elif "type" in message:
  100. gui._manage_ws_message(message["type"], message) # type: ignore[attr-defined]
  101. @self._ws.on("connect")
  102. def handle_connect():
  103. gui._handle_connect() # type: ignore[attr-defined]
  104. @self._ws.on("disconnect")
  105. def handle_disconnect():
  106. gui._handle_disconnect() # type: ignore[attr-defined]
  107. def _get_default_handler(
  108. self,
  109. static_folder: str,
  110. template_folder: str,
  111. title: str,
  112. favicon: str,
  113. root_margin: str,
  114. scripts: t.List[str],
  115. styles: t.List[str],
  116. version: str,
  117. client_config: t.Dict[str, t.Any],
  118. watermark: t.Optional[str],
  119. css_vars: str,
  120. base_url: str,
  121. ) -> Blueprint:
  122. taipy_bp = Blueprint("Taipy", __name__, static_folder=static_folder, template_folder=template_folder)
  123. # Serve static react build
  124. @taipy_bp.route("/", defaults={"path": ""})
  125. @taipy_bp.route("/<path:path>")
  126. def my_index(path):
  127. resource_handler_id = request.cookies.get(_Server._RESOURCE_HANDLER_ARG, None)
  128. if resource_handler_id is not None:
  129. resource_handler = _ExternalResourceHandlerManager().get(resource_handler_id)
  130. if resource_handler is None:
  131. reload_html = "<html><head><style>body {background-color: black; margin: 0;}</style></head><body><script>location.reload();</script></body></html>" # noqa: E501
  132. response = make_response(render_template_string(reload_html), 400)
  133. response.set_cookie(
  134. _Server._RESOURCE_HANDLER_ARG, "", secure=request.is_secure, httponly=True, expires=0, path="/"
  135. )
  136. return response
  137. try:
  138. return resource_handler.get_resources(path, static_folder, base_url)
  139. except Exception as e:
  140. raise RuntimeError("Can't get resources from custom resource handler") from e
  141. if path == "" or path == "index.html" or "." not in path:
  142. try:
  143. return render_template(
  144. "index.html",
  145. title=title,
  146. favicon=f"{favicon}?version={version}",
  147. root_margin=root_margin,
  148. watermark=watermark,
  149. config=client_config,
  150. scripts=scripts,
  151. styles=styles,
  152. version=version,
  153. css_vars=css_vars,
  154. base_url=base_url,
  155. )
  156. except Exception:
  157. raise RuntimeError(
  158. "Something is wrong with the taipy-gui front-end installation. Check that the js bundle has been properly built (is Node.js installed?)." # noqa: E501
  159. ) from None
  160. if path == "taipy.status.json":
  161. return self.direct_render_json(self._gui._serve_status(pathlib.Path(template_folder) / path)) # type: ignore[attr-defined]
  162. if (file_path := str(os.path.normpath((base_path := static_folder + os.path.sep) + path))).startswith(
  163. base_path
  164. ) and os.path.isfile(file_path):
  165. return send_from_directory(base_path, path)
  166. # use the path mapping to detect and find resources
  167. for k, v in self.__path_mapping.items():
  168. if (
  169. path.startswith(f"{k}/")
  170. and (
  171. file_path := str(os.path.normpath((base_path := v + os.path.sep) + path[len(k) + 1 :]))
  172. ).startswith(base_path)
  173. and os.path.isfile(file_path)
  174. ):
  175. return send_from_directory(base_path, path[len(k) + 1 :])
  176. if (
  177. hasattr(__main__, "__file__")
  178. and (
  179. file_path := str(
  180. os.path.normpath((base_path := os.path.dirname(__main__.__file__) + os.path.sep) + path)
  181. )
  182. ).startswith(base_path)
  183. and os.path.isfile(file_path)
  184. and not self._is_ignored(file_path)
  185. ):
  186. return send_from_directory(base_path, path)
  187. if (
  188. (
  189. file_path := str(os.path.normpath((base_path := self._gui._root_dir + os.path.sep) + path)) # type: ignore[attr-defined]
  190. ).startswith(base_path)
  191. and os.path.isfile(file_path)
  192. and not self._is_ignored(file_path)
  193. ):
  194. return send_from_directory(base_path, path)
  195. return ("", 404)
  196. return taipy_bp
  197. def direct_render_json(self, data):
  198. return jsonify(data)
  199. def get_server_instance(self):
  200. return self._server
  201. def get_port(self):
  202. return self._port
  203. def test_client(self):
  204. return t.cast(Flask, self._server).test_client()
  205. @contextmanager
  206. def test_request_context(self, path, data=None):
  207. if not isinstance(self._server, Flask):
  208. raise RuntimeError("Flask server is not initialized")
  209. with self._server.test_request_context(path, data=data):
  210. yield
  211. def _run_notebook(self):
  212. self._is_running = True
  213. self._ws.run(self._server, host=self._host, port=self._port, debug=False, use_reloader=False)
  214. def _get_async_mode(self) -> str:
  215. return self._ws.async_mode # type: ignore[attr-defined]
  216. def _apply_patch(self):
  217. if self._get_async_mode() == "gevent" and util.find_spec("gevent"):
  218. from gevent import get_hub, monkey
  219. get_hub().NOT_ERROR += (KeyboardInterrupt,)
  220. if not monkey.is_module_patched("time"):
  221. monkey.patch_time()
  222. if self._get_async_mode() == "eventlet" and util.find_spec("eventlet"):
  223. from eventlet import monkey_patch, patcher # type: ignore[reportMissingImport]
  224. if not patcher.is_monkey_patched("time"):
  225. monkey_patch(time=True)
  226. def send_ws_message(self, *args, **kwargs):
  227. self._ws.emit("message", *args, **kwargs)
  228. def save_uploaded_file(self, file, path):
  229. file.save(path)
  230. def run(
  231. self,
  232. host,
  233. port,
  234. client_url,
  235. debug,
  236. use_reloader,
  237. server_log,
  238. run_in_thread,
  239. allow_unsafe_werkzeug,
  240. notebook_proxy,
  241. port_auto_ranges,
  242. ):
  243. host_value = host if host != "0.0.0.0" else "localhost"
  244. self._host = host
  245. if port == "auto":
  246. port = self._get_random_port(port_auto_ranges)
  247. server_url = f"http://{host_value}:{port}"
  248. self._port = port
  249. if _is_in_notebook() and notebook_proxy: # pragma: no cover
  250. from ...utils.proxy import NotebookProxy
  251. # Start proxy if not already started
  252. self._proxy = NotebookProxy(gui=self._gui, listening_port=port)
  253. self._proxy.run()
  254. self._port = self._get_random_port()
  255. if _is_in_notebook() or run_in_thread:
  256. runtime_manager = _RuntimeManager()
  257. runtime_manager.add_gui(self._gui, port)
  258. if debug and not is_running_from_reloader() and _is_port_open(host_value, port):
  259. raise ConnectionError(
  260. 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
  261. )
  262. if not server_log:
  263. log = logging.getLogger("werkzeug")
  264. log.disabled = True
  265. if not is_running_from_reloader():
  266. _TaipyLogger._get_logger().info(f" * Server starting on {server_url}")
  267. else:
  268. _TaipyLogger._get_logger().info(f" * Server reloaded on {server_url}")
  269. if client_url is not None:
  270. client_url = client_url.format(port=port)
  271. _TaipyLogger._get_logger().info(f" * Application is accessible at {client_url}")
  272. if not is_running_from_reloader() and self._gui._get_config("run_browser", False): # type: ignore[attr-defined]
  273. webbrowser.open(client_url or server_url, new=2)
  274. if _is_in_notebook() or run_in_thread:
  275. self._thread = KThread(target=self._run_notebook)
  276. self._thread.start()
  277. return
  278. self._is_running = True
  279. run_config = {
  280. "app": self._server,
  281. "host": host,
  282. "port": port,
  283. "debug": debug,
  284. "use_reloader": use_reloader,
  285. }
  286. if self.__ssl_context is not None:
  287. run_config["ssl_context"] = self.__ssl_context
  288. # flask-socketio specific conditions for 'allow_unsafe_werkzeug' parameters to be popped out of kwargs
  289. if self._get_async_mode() == "threading" and (not sys.stdin or not sys.stdin.isatty()):
  290. run_config = {**run_config, "allow_unsafe_werkzeug": allow_unsafe_werkzeug}
  291. try:
  292. self._ws.run(**run_config)
  293. except KeyboardInterrupt:
  294. pass
  295. def is_running(self):
  296. return self._is_running
  297. def stop_thread(self):
  298. if hasattr(self, "_thread") and self._thread.is_alive() and self._is_running:
  299. self._is_running = False
  300. with contextlib.suppress(Exception):
  301. if self._get_async_mode() == "gevent":
  302. if self._ws.wsgi_server is not None: # type: ignore[attr-defined]
  303. self._ws.wsgi_server.stop() # type: ignore[attr-defined]
  304. else:
  305. self._thread.kill()
  306. else:
  307. self._thread.kill()
  308. while _is_port_open(self._host, self._port):
  309. time.sleep(0.1)
  310. def stop_proxy(self):
  311. if hasattr(self, "_proxy"):
  312. self._proxy.stop()