summaryrefslogtreecommitdiffstats
path: root/openbb_platform/obbject_extensions/charting/openbb_charting/core/backend.py
diff options
context:
space:
mode:
Diffstat (limited to 'openbb_platform/obbject_extensions/charting/openbb_charting/core/backend.py')
-rw-r--r--openbb_platform/obbject_extensions/charting/openbb_charting/core/backend.py522
1 files changed, 522 insertions, 0 deletions
diff --git a/openbb_platform/obbject_extensions/charting/openbb_charting/core/backend.py b/openbb_platform/obbject_extensions/charting/openbb_charting/core/backend.py
new file mode 100644
index 00000000000..ce8b706f5cc
--- /dev/null
+++ b/openbb_platform/obbject_extensions/charting/openbb_charting/core/backend.py
@@ -0,0 +1,522 @@
+"""Backend for Plotly."""
+
+import asyncio
+import atexit
+import json
+import os
+import re
+import subprocess
+import sys
+import warnings
+from multiprocessing import current_process
+from pathlib import Path
+from threading import Thread
+from typing import TYPE_CHECKING, Any, Dict, Optional, Union
+
+import aiohttp
+import pandas as pd
+import plotly.graph_objects as go
+from openbb_core.env import Env
+from packaging import version
+from reportlab.graphics import renderPDF
+from svglib.svglib import svg2rlg
+
+if TYPE_CHECKING:
+ from openbb_core.app.model.charts.charting_settings import ChartingSettings
+
+
+# pylint: disable=C0415
+try:
+ from pywry import PyWry
+except ImportError as e:
+ if Env().DEBUG_MODE:
+ print(f"\033[91m{e}\033[0m") # noqa: T201
+ # pylint: disable=C0412
+ from .dummy_backend import DummyBackend
+
+ class PyWry(DummyBackend): # type: ignore
+ """Dummy backend for charts."""
+
+
+try:
+ from IPython import get_ipython
+
+ if "IPKernelApp" not in get_ipython().config:
+ raise ImportError("console")
+ if (
+ "parent_header"
+ in get_ipython().kernel._parent_ident # pylint: disable=protected-access
+ ):
+ raise ImportError("notebook")
+except (ImportError, AttributeError):
+ JUPYTER_NOTEBOOK = False
+else:
+ JUPYTER_NOTEBOOK = True
+
+PLOTS_CORE_PATH = Path(__file__).parent.resolve()
+PLOTLYJS_PATH = PLOTS_CORE_PATH / "assets" / "plotly-2.24.2.min.js"
+BACKEND = None
+
+
+class Backend(PyWry):
+ """Custom backend for Plotly."""
+
+ def __new__(cls, *args, **kwargs): # pylint: disable=W0613
+ """Create a singleton instance of the backend."""
+ if not hasattr(cls, "instance"):
+ cls.instance = super().__new__(cls) # pylint: disable=E1120
+ return cls.instance
+
+ def __init__(
+ self,
+ charting_settings: "ChartingSettings",
+ daemon: bool = True,
+ max_retries: int = 30,
+ proc_name: str = "OpenBB Terminal",
+ ):
+ self.charting_settings = charting_settings
+
+ has_version = hasattr(PyWry, "__version__")
+ init_kwargs: Dict[str, Any] = dict(daemon=daemon, max_retries=max_retries)
+
+ if has_version and version.parse(PyWry.__version__) >= version.parse("0.4.8"):
+ init_kwargs.update(dict(proc_name=proc_name))
+
+ super().__init__(**init_kwargs)
+
+ self.plotly_html: Path = (PLOTS_CORE_PATH / "plotly.html").resolve()
+ self.table_html: Path = (PLOTS_CORE_PATH / "table.html").resolve()
+ self.isatty = (
+ not JUPYTER_NOTEBOOK
+ and sys.stdin.isatty()
+ and current_process().name == "MainProcess"
+ )
+ if has_version and PyWry.__version__ == "0.0.0":
+ self.isatty = False
+
+ self.WIDTH, self.HEIGHT = 1400, 762
+ self.logged_in: bool = False
+
+ atexit.register(self.close)
+
+ def set_window_dimensions(self):
+ """Set the window dimensions."""
+ width = self.charting_settings.plot_pywry_width or 1400
+ height = self.charting_settings.plot_pywry_height or 762
+
+ self.WIDTH, self.HEIGHT = int(width), int(height)
+
+ def get_pending(self) -> list:
+ """Get the pending data that has not been sent to the backend."""
+ # pylint: disable=W0201,E0203
+ pending = self.outgoing + self.init_engine
+ self.outgoing: list = []
+ self.init_engine: list = []
+ return pending
+
+ def get_plotly_html(self) -> Path:
+ """Get the plotly html file."""
+ self.set_window_dimensions()
+ if self.plotly_html.exists():
+ return self.plotly_html
+
+ warnings.warn(
+ "[bold red]plotly.html file not found, check the path:[/]"
+ f"[green]{PLOTS_CORE_PATH / 'plotly.html'}[/]"
+ )
+ self.max_retries = 0 # pylint: disable=W0201
+ raise FileNotFoundError
+
+ def get_table_html(self) -> Path:
+ """Get the table html file."""
+ self.set_window_dimensions()
+ if self.table_html.exists():
+ return self.table_html
+ warnings.warn(
+ "[bold red]table.html file not found, check the path:[/]"
+ f"[green]{PLOTS_CORE_PATH / 'table.html'}[/]"
+ )
+ self.max_retries = 0 # pylint: disable=W0201
+ raise FileNotFoundError
+
+ def get_window_icon(self) -> Optional[Path]:
+ """Get the window icon."""
+ icon_path = PLOTS_CORE_PATH / "assets" / "Terminal_icon.png"
+ if icon_path.exists():
+ return icon_path
+ return None
+
+ def get_json_update(
+ self,
+ cmd_loc: Optional[str] = None,
+ theme: Optional[str] = None,
+ ) -> dict:
+ """Get the json update for the backend."""
+
+ posthog: Dict[str, Any] = dict(collect_logs=self.charting_settings.log_collect)
+ if (
+ self.charting_settings.log_collect
+ and self.charting_settings.user_uuid
+ and not self.logged_in
+ ):
+ self.logged_in = True
+ posthog.update(
+ dict(
+ user_id=self.charting_settings.user_uuid,
+ email=self.charting_settings.user_email,
+ )
+ )
+
+ return dict(
+ theme=theme or self.charting_settings.chart_style,
+ log_id=self.charting_settings.app_id,
+ pywry_version=self.__version__,
+ platform_version=self.charting_settings.version,
+ python_version=self.charting_settings.python_version,
+ posthog=posthog,
+ command_location=cmd_loc,
+ )
+
+ def send_figure(
+ self,
+ fig: go.Figure,
+ export_image: Optional[Union[Path, str]] = "",
+ command_location: Optional[str] = "",
+ ):
+ """Send a Plotly figure to the backend.
+
+ Parameters
+ ----------
+ fig : go.Figure
+ Plotly figure to send to backend.
+ export_image : str, optional
+ Path to export image to, by default ""
+ command_location : str, optional
+ Location of the command, by default "".
+ We can use the route here to display it on the chart title.
+ """
+ self.check_backend()
+ # pylint: disable=C0415
+
+ title = "Interactive Chart"
+
+ fig.layout.title.text = re.sub(
+ r"<[^>]*>", "", fig.layout.title.text if fig.layout.title.text else title
+ )
+
+ fig.layout.height += 69
+
+ if export_image and isinstance(export_image, str):
+ export_image = Path(export_image).resolve()
+
+ json_data = json.loads(fig.to_json())
+
+ json_data.update(self.get_json_update(command_location))
+
+ outgoing = dict(
+ html=self.get_plotly_html(),
+ json_data=json_data,
+ export_image=export_image,
+ **self.get_kwargs(command_location),
+ )
+ self.send_outgoing(outgoing)
+
+ if export_image and isinstance(export_image, Path):
+ self.loop.run_until_complete(self.process_image(export_image))
+
+ async def process_image(self, export_image: Path):
+ """Check if the image has been exported to the path."""
+ pdf = export_image.suffix == ".pdf"
+ img_path = export_image.resolve()
+
+ checks = 0
+ while not img_path.exists():
+ await asyncio.sleep(0.2)
+ checks += 1
+ if checks > 50:
+ break
+
+ if pdf:
+ img_path = img_path.rename(img_path.with_suffix(".svg"))
+
+ if img_path.exists(): # noqa: SIM102
+ if pdf:
+ drawing = svg2rlg(img_path)
+ img_path.unlink(missing_ok=True)
+ renderPDF.drawToFile(drawing, str(export_image))
+
+ if self.charting_settings.plot_open_export:
+ if sys.platform == "win32":
+ os.startfile(export_image) # nosec: B606 # noqa: S606
+ else:
+ opener = "open" if sys.platform == "darwin" else "xdg-open"
+ subprocess.check_call(
+ [opener, export_image] # nosec: B603 # noqa: S603
+ )
+
+ def send_table(
+ self,
+ df_table: pd.DataFrame,
+ title: str = "",
+ source: str = "",
+ theme: str = "dark",
+ command_location: Optional[str] = "",
+ ):
+ """Send table data to the backend to be displayed in a table.
+
+ Parameters
+ ----------
+ df_table : pd.DataFrame
+ Dataframe to send to backend.
+ title : str, optional
+ Title to display in the window, by default ""
+ source : str, optional
+ Source of the data, by default ""
+ theme : light or dark, optional
+ Theme of the table, by default "light"
+ """
+ self.check_backend()
+
+ if title:
+ # We remove any html tags and markdown from the title
+ title = re.sub(r"<[^>]*>", "", title)
+ title = re.sub(r"\[\/?[a-z]+\]", "", title)
+
+ # we get the length of each column using the max length of the column
+ # name and the max length of the column values as the column width
+ columnwidth = [
+ max(
+ len(str(df_table[col].name)),
+ df_table[col].astype(str).str.len().max(),
+ )
+ for col in df_table.columns
+ if hasattr(df_table[col], "name") and hasattr(df_table[col], "dtype")
+ ]
+
+ # we add a percentage of max to the min column width
+ columnwidth = [
+ int(x + (max(columnwidth) - min(columnwidth)) * 0.2) for x in columnwidth
+ ]
+
+ # in case of a very small table we set a min width
+ width = max(int(min(sum(columnwidth) * 9.7, self.WIDTH + 100)), 800)
+
+ json_data = json.loads(df_table.to_json(orient="split", date_format="iso"))
+ json_data.update(
+ dict(
+ title=title,
+ source=source or "",
+ **self.get_json_update(command_location, theme or "dark"),
+ )
+ )
+
+ outgoing = dict(
+ html=self.get_table_html(),
+ json_data=json.dumps(json_data),
+ width=width,
+ height=self.HEIGHT - 100,
+ **self.get_kwargs(command_location),
+ )
+ self.send_outgoing(outgoing)
+
+ def send_url(
+ self,
+ url: str,
+ title: str = "",
+ width: Optional[int] = None,
+ height: Optional[int] = None,
+ ):
+ """Send a URL to the backend to be displayed in a window.
+
+ Parameters
+ ----------
+ url : str
+ URL to display in the window.
+ title : str, optional
+ Title to display in the window, by default ""
+ width : int, optional
+ Width of the window, by default 1200
+ height : int, optional
+ Height of the window, by default 800
+ """
+ self.check_backend()
+ script = f"""
+ <script>
+ window.location.replace("{url}");
+ </script>
+ """
+ outgoing = dict(
+ html=script,
+ **self.get_kwargs(title),
+ width=width or self.WIDTH,
+ height=height or self.HEIGHT,
+ )
+ self.send_outgoing(outgoing)
+
+ def get_kwargs(self, title: Optional[str] = "") -> dict:
+ """Get the kwargs for the backend."""
+ return {
+ "title": "OpenBB Platform" + (f" - {title}" if title else ""),
+ "icon": self.get_window_icon(),
+ "download_path": str(self.charting_settings.user_exports_directory),
+ }
+
+ def start(self, debug: bool = False, headless: bool = False):
+ """Start the backend WindowManager process."""
+ if self.isatty:
+ super().start(debug, headless)
+
+ def check_backend(self):
+ """Override to check if isatty."""
+ if not self.isatty:
+ return None
+
+ message = (
+ "[bold red]PyWry version 0.5.12 or higher is required to use the "
+ "OpenBB Plots backend.[/]\n"
+ "[yellow]Please update pywry with 'pip install pywry --upgrade'[/]"
+ )
+ if not hasattr(PyWry, "__version__"):
+ try:
+ # pylint: disable=C0415
+ from pywry import __version__ as pywry_version
+ except ImportError:
+ self.max_retries = 0
+ return warnings.warn(message)
+
+ PyWry.__version__ = pywry_version # pylint: disable=W0201
+
+ if version.parse(PyWry.__version__) < version.parse("0.5.12"):
+ self.max_retries = 0 # pylint: disable=W0201
+ return warnings.warn(message)
+
+ if version.parse(PyWry.__version__) > version.parse("0.5.12"):
+ return super().check_backend()
+
+ try:
+ return self.loop.run_until_complete(super().check_backend())
+ except Exception:
+ return None
+
+ def close(self, reset: bool = False):
+ """Close the backend."""
+ if reset:
+ self.max_retries = 50 # pylint: disable=W0201
+
+ super().close()
+
+ async def get_results(self, description: str) -> dict:
+ """Wait for completion of interactive task and return the data.
+
+ Parameters
+ ----------
+ description : str
+ Description of the task to console print while waiting.
+
+ Returns
+ -------
+ dict
+ The data returned from pywry backend.
+ """
+ warnings.warn(
+ f"[green]{description}[/]\n\n"
+ "[yellow]If the window is closed you can continue by pressing Ctrl+C.[/]"
+ )
+ while True:
+ try:
+ data: dict = self.recv.get(block=False) or {}
+ if data.get("result", False):
+ return json.loads(data["result"])
+ except Exception: # pylint: disable=W0703
+ await asyncio.sleep(0.1)
+
+ await asyncio.sleep(1)
+
+ def call_hub(self, login: bool = True) -> Optional[dict]:
+ """Call the hub to login or logout.
+
+ Parameters
+ ----------
+ login : bool, optional
+ Whether to login or logout, by default True
+
+ Returns
+ -------
+ Optional[dict]
+ The user data if login was successful, None otherwise.
+ """
+ self.check_backend()
+ endpoint = {True: "login", False: "logout"}[login]
+
+ outgoing = dict(
+ json_data=dict(url=f"https://my.openbb.co/{endpoint}?pywry=true"),
+ **self.get_kwargs(endpoint.title()),
+ width=900,
+ height=800,
+ )
+ self.send_outgoing(outgoing)
+
+ messages_dict = dict(
+ login=dict(
+ message="Welcome to OpenBB Terminal! Please login to continue.",
+ interrupt="Window closed without authentication. Please proceed below.",
+ ),
+ logout=dict(
+ message="Sending logout request", interrupt="Please login to continue."
+ ),
+ )
+
+ try:
+ return self.loop.run_until_complete(
+ self.get_results(messages_dict[endpoint]["message"])
+ )
+ except KeyboardInterrupt:
+ warnings.warn(f"\n[red]{messages_dict[endpoint]['interrupt']}[/red]")
+ return None
+
+
+async def download_plotly_js():
+ """Download or updates plotly.js to the assets folder."""
+ js_filename = PLOTLYJS_PATH.name
+ try:
+ # we use aiohttp to download plotly.js
+ # this is so we don't have to block the main thread
+ async with aiohttp.ClientSession(
+ connector=aiohttp.TCPConnector(verify_ssl=False)
+ ) as session, session.get(f"https://cdn.plot.ly/{js_filename}") as resp:
+ with open(str(PLOTLYJS_PATH), "wb") as f:
+ while True:
+ chunk = await resp.content.read(1024)
+ if not chunk:
+ break
+ f.write(chunk)
+
+ # We delete the old version of plotly.js
+ for file in (PLOTS_CORE_PATH / "assets").glob("plotly*.js"):
+ if file.name != js_filename:
+ file.unlink(missing_ok=True)
+
+ except Exception as err: # pylint: disable=W0703
+ warnings.warn(f"Error downloading plotly.js: {err}")
+
+
+# To avoid having plotly.js in the repo, we download it if it's not present
+if not PLOTLYJS_PATH.exists() and not JUPYTER_NOTEBOOK:
+ # We run this in a thread so we don't block the main thread
+ Thread(target=asyncio.run, args=(download_plotly_js(),)).start()
+
+
+def create_backend(charting_settings: Optional["ChartingSettings"] = None):
+ # # pylint: disable=import-outside-toplevel
+ from openbb_core.app.model.charts.charting_settings import ChartingSettings
+
+ charting_settings = charting_settings or ChartingSettings()
+ global BACKEND # pylint: disable=W0603 # noqa
+ if BACKEND is None:
+ BACKEND = Backend(charting_settings)
+
+
+def get_backend() -> Backend:
+ if BACKEND is None:
+ raise ValueError("Backend not created")
+ return BACKEND