mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-10-29 13:32:08 +00:00
504 lines
16 KiB
Python
504 lines
16 KiB
Python
"""Backend for Plotly."""
|
|
import asyncio
|
|
import atexit
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess # nosec: B404
|
|
import sys
|
|
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 openbb_terminal.core.plots.no_import import DummyBackend
|
|
|
|
class PyWry(DummyBackend): # type: ignore
|
|
pass
|
|
|
|
|
|
from svglib.svglib import svg2rlg
|
|
|
|
from openbb_terminal.base_helpers import console
|
|
from openbb_terminal.core.session.current_system import get_current_system
|
|
from openbb_terminal.core.session.current_user import get_current_user
|
|
|
|
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()
|
|
and not get_current_system().TEST_MODE
|
|
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."""
|
|
current_user = get_current_user()
|
|
width = current_user.preferences.PLOT_PYWRY_WIDTH or 1400
|
|
height = 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
|
|
|
|
console.print(
|
|
"[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
|
|
console.print(
|
|
"[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: str, theme: Optional[str] = None) -> dict:
|
|
"""Get the json update for the backend."""
|
|
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,
|
|
pywry_version=self.__version__,
|
|
terminal_version=current_system.VERSION,
|
|
python_version=current_system.PYTHON_VERSION,
|
|
posthog=posthog,
|
|
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
|
|
from openbb_terminal.helper_funcs import 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():
|
|
if pdf:
|
|
drawing = svg2rlg(img_path)
|
|
img_path.unlink(missing_ok=True)
|
|
renderPDF.drawToFile(drawing, str(export_image))
|
|
|
|
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
|
|
from openbb_terminal.helper_funcs import 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"""
|
|
<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: str = "") -> dict:
|
|
"""Get the kwargs for the backend."""
|
|
return {
|
|
"title": f"OpenBB - {title}",
|
|
"icon": self.get_window_icon(),
|
|
"download_path": str(get_current_user().preferences.USER_EXPORTS_DIRECTORY),
|
|
}
|
|
|
|
def start(self, debug: bool = False): # pylint: disable=W0221
|
|
"""Start the backend WindowManager process."""
|
|
if self.isatty:
|
|
super().start(debug)
|
|
|
|
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 console.print(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 console.print(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.
|
|
"""
|
|
console.print(
|
|
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
|
|
pass
|
|
|
|
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:
|
|
console.print(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
|