"""Backend for Plotly.""" import asyncio import atexit import json # import os # noqa # TODO: see commented out block below import re # import subprocess # nosec: B404 # noqa # TODO: see commented out block below import sys import warnings from multiprocessing import current_process from pathlib import Path from typing import Any, Dict, Optional, Union import aiohttp import pandas as pd import plotly.graph_objects as go from packaging import version from reportlab.graphics import renderPDF # pylint: disable=C0415 try: from pywry import PyWry except ImportError as e: print(f"\033[91m{e}\033[0m") # pylint: disable=C0412 from .no_import import DummyBackend class PyWry(DummyBackend): # type: ignore """Dummy backend for charts.""" from svglib.svglib import svg2rlg 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, daemon: bool = True, max_retries: int = 30, proc_name: str = "OpenBB Terminal", ): 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() # TODO : figure this system preference - and not get_current_system().TEST_MODE # TODO : figure this user preference - and not get_current_user().preferences.ENABLE_QUICK_EXIT 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.""" # TODO : figure this user preferences # current_user = get_current_user() width = 1400 # current_user.preferences.PLOT_PYWRY_WIDTH or 1400 height = 762 # current_user.preferences.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 # pylint: disable=W0613 # TODO : remove this once theme argument is used def get_json_update( self, cmd_loc: str, theme: Optional[str] = None, ) -> dict: """Get the json update for the backend.""" # TODO : figure this functions out (specially regarding the user and system settings) # current_user = get_current_user() # current_system = get_current_system() # posthog: Dict[str, Any] = dict(collect_logs=current_system.LOG_COLLECT) # if ( # current_system.LOG_COLLECT # and current_user.profile.email # and not self.logged_in # ): # self.logged_in = True # posthog.update( # dict( # user_id=current_user.profile.uuid, # email=current_user.profile.email, # ) # ) return dict( # theme=theme or current_user.preferences.CHART_STYLE, # log_id=current_system.LOGGING_APP_ID, theme="dark", pywry_version=self.__version__, # terminal_version=current_system.VERSION, # python_version=current_system.PYTHON_VERSION, # posthog=posthog, posthog=dict(), command_location=cmd_loc, ) def send_figure( self, fig: go.Figure, export_image: Optional[Union[Path, 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 "" """ self.check_backend() # pylint: disable=C0415 # TODO : check if we need the command_location # from openbb_terminal.helper_funcs import command_location command_location = "" 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)) # TODO : figure out user preferences # if get_current_user().preferences.PLOT_OPEN_EXPORT: # if sys.platform == "win32": # os.startfile(export_image) # nosec: B606 # else: # opener = "open" if sys.platform == "darwin" else "xdg-open" # subprocess.check_call([opener, export_image]) # nosec: B603 def send_table( self, df_table: pd.DataFrame, title: str = "", source: str = "", theme: str = "dark", ): """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) # pylint: disable=C0415 # TODO : check if we need the command_location # from openbb_terminal.helper_funcs import command_location command_location = "" 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""" """ 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: str = "") -> dict: """Get the kwargs for the backend.""" return { "title": f"OpenBB - {title}", "icon": self.get_window_icon(), "download_path": str( Path.home() / "OpenBBUserData" / "exports" ), # TODO : figure out user preferences - str(get_current_user().preferences.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: async with 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 print(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: asyncio.run(download_plotly_js()) def plots_backend() -> Backend: """Get the backend.""" global BACKEND # pylint: disable=W0603 # noqa if BACKEND is None: BACKEND = Backend() return BACKEND