server.py 16 KB

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