"""Helper classes.""" __docformat__ = "numpy" import os from pathlib import Path import argparse import json from importlib import machinery, util from typing import Union, List, Dict, Optional import matplotlib.pyplot as plt from matplotlib import font_manager, ticker from openbb_terminal.core.config.paths import ( MISCELLANEOUS_DIRECTORY, USER_DATA_DIRECTORY, ) # pylint: disable=too-few-public-methods class ModelsNamespace: """A namespace placeholder for the menu models. This class is used in all api wrappers to create a `models` namespace and import all the model functions. """ def __init__(self, folders: Union[str, List[str]]) -> None: """Import all menu models into the models namespace. Instantiation of the namespace requires either a path to the folder that contains model files or a list of such folders. Parameters ---------- folders : Union[str, List[str]] a folder or a list of folders to import models from """ if isinstance(folders, str): folders = [folders] for folder in folders: menu_models = [ ( f.replace("_model.py", ""), os.path.abspath(os.path.join(folder, f)), ) for f in os.listdir(folder) if f.endswith("_model.py") ] for model_name, model_file in menu_models: loader = machinery.SourceFileLoader(model_name, model_file) spec = util.spec_from_loader(model_name, loader) if spec is not None: setattr(self, model_name, util.module_from_spec(spec)) loader.exec_module(getattr(self, model_name)) else: pass # pylint: disable=R0902 class TerminalStyle: """The class that helps with handling of style configurations. It serves styles for 3 libraries. For `Matplotlib` this class serves absolute paths to the .mplstyle files. For `Matplotlib Finance` and `Rich` this class serves custom styles as python dictionaries. """ DEFAULT_STYLES_LOCATION = MISCELLANEOUS_DIRECTORY / "styles" / "default" USER_STYLES_LOCATION = USER_DATA_DIRECTORY / "styles" / "user" mpl_styles_available: Dict[str, str] = {} mpl_style: str = "" mpl_rcparams_available: Dict[str, str] = {} mpl_rcparams: Dict = {} mpf_styles_available: Dict[str, str] = {} mpf_style: Dict = {} console_styles_available: Dict[str, str] = {} console_style: Dict[str, str] = {} down_color: str = "" up_color: str = "" xticks_rotation: str = "" tight_layout_padding: int = 0 pie_wedgeprops: Dict = {} pie_startangle: int = 0 line_width: float = 1.5 volume_bar_width: float = 0.8 def __init__( self, mpl_style: Optional[str] = "", mpf_style: Optional[str] = "", console_style: Optional[str] = "", ) -> None: """Instantiate a terminal style class The stylesheet files should be placed to the `styles/default` or `styles/user` folders. The parameters required for class instantiation are stylesheet names without extensions (following matplotlib convention). Ex. `styles/default/boring.mplstyle` should be passed as `boring`. Parameters ---------- mpl_style : str, optional Style name without extension, by default "" mpf_style : str, optional Style name without extension, by default "" console_style : str, optional Style name without extension, by default "" """ # To import all styles from terminal repo folder to user data for folder in [self.DEFAULT_STYLES_LOCATION, self.USER_STYLES_LOCATION]: self.load_available_styles_from_folder(folder) self.load_custom_fonts_from_folder(folder) if mpl_style in self.mpl_styles_available: self.mpl_style = self.mpl_styles_available[mpl_style] else: self.mpl_style = self.mpl_styles_available["dark"] if mpl_style in self.mpl_rcparams_available: with open(self.mpl_rcparams_available[mpl_style]) as stylesheet: self.mpl_rcparams = json.load(stylesheet) else: with open(self.mpl_rcparams_available["dark"]) as stylesheet: self.mpl_rcparams = json.load(stylesheet) if mpf_style in self.mpf_styles_available: with open(self.mpf_styles_available[mpf_style]) as stylesheet: self.mpf_style = json.load(stylesheet) self.mpf_style["base_mpl_style"] = self.mpl_style else: with open(self.mpf_styles_available["dark"]) as stylesheet: self.mpf_style = json.load(stylesheet) self.mpf_style["base_mpl_style"] = self.mpl_style if "openbb_config" in self.console_styles_available: with open(self.console_styles_available["openbb_config"]) as stylesheet: self.console_style = json.load(stylesheet) elif console_style in self.console_styles_available: with open(self.console_styles_available[console_style]) as stylesheet: self.console_style = json.load(stylesheet) else: with open(self.console_styles_available["dark"]) as stylesheet: self.console_style = json.load(stylesheet) self.applyMPLstyle() def load_custom_fonts_from_folder(self, folder: Path) -> None: """Load custom fonts form folder. TTF and OTF fonts are loaded into the mpl font manager and are available for selection in mpl by their name (for example "Consolas" or "Hack"). Parameters ---------- folder : str Path to the folder containing the fonts """ if not folder.exists(): return for font_file in folder.iterdir(): if not font_file.is_file(): continue if font_file.name.endswith(".otf") or font_file.name.endswith(".ttf"): font_path = os.path.abspath(os.path.join(folder, font_file)) font_manager.fontManager.addfont(font_path) def load_available_styles_from_folder(self, folder: Path) -> None: """Load custom styles from folder. Parses the styles/default and styles/user folders and loads style files. To be recognized files need to follow a naming convention: *.mplstyle - matplotlib stylesheets *.mplrc.json - matplotlib rc stylesheets that are not handled by mplstyle *.mpfstyle.json - matplotlib finance stylesheets *.richstyle.json - rich stylesheets Parameters ---------- folder : str Path to the folder containing the stylesheets """ if not folder.exists(): return for stf in folder.iterdir(): if not stf.is_file(): continue if stf.name.endswith(".mplstyle"): self.mpl_styles_available[ stf.name.replace(".mplstyle", "") ] = os.path.join(folder, stf) elif stf.name.endswith(".mplrc.json"): self.mpl_rcparams_available[ stf.name.replace(".mplrc.json", "") ] = os.path.join(folder, stf) elif stf.name.endswith(".mpfstyle.json"): self.mpf_styles_available[ stf.name.replace(".mpfstyle.json", "") ] = os.path.join(folder, stf) elif stf.name.endswith(".richstyle.json"): self.console_styles_available[ stf.name.replace(".richstyle.json", "") ] = os.path.join(folder, stf) def applyMPLstyle(self): """Apply style to the current matplotlib context.""" plt.style.use(self.mpl_style) self.xticks_rotation = self.mpl_rcparams["xticks_rotation"] self.tight_layout_padding = self.mpl_rcparams["tight_layout_padding"] self.pie_wedgeprops = self.mpl_rcparams["pie_wedgeprops"] self.pie_startangle = self.mpl_rcparams["pie_startangle"] self.mpf_style["mavcolors"] = plt.rcParams["axes.prop_cycle"].by_key()["color"] self.down_color = self.mpf_style["marketcolors"]["volume"]["down"] self.up_color = self.mpf_style["marketcolors"]["volume"]["up"] self.line_width = plt.rcParams["lines.linewidth"] try: self.volume_bar_width = self.mpl_rcparams["volume_bar_width"] except Exception(): pass def get_colors(self, reverse: bool = False) -> List: """Get hex color sequence from the stylesheet.""" plt.style.use(self.mpl_style) colors = plt.rcParams["axes.prop_cycle"].by_key()["color"] if reverse: colors.reverse() return colors def style_primary_axis( self, ax: plt.Axes, data_index: Optional[List[int]] = None, tick_labels: Optional[List[str]] = None, ): """Apply styling to a primary axis. Parameters ---------- ax : plt.Axes A matplolib axis """ ax.yaxis.set_label_position("right") ax.grid(axis="both", visible=True, zorder=0) if ( all([data_index, tick_labels]) and isinstance(data_index, list) and isinstance(tick_labels, list) ): ax.xaxis.set_major_formatter( ticker.FuncFormatter( lambda value, _: tick_labels[int(value)] if int(value) in data_index else "" ) ) ax.xaxis.set_major_locator(ticker.MaxNLocator(6, integer=True)) ax.tick_params(axis="x", labelrotation=self.xticks_rotation) def style_twin_axis(self, ax: plt.Axes): """Apply styling to a twin axis. Parameters ---------- ax : plt.Axes A matplolib axis """ ax.yaxis.set_label_position("left") def style_twin_axes(self, ax1: plt.Axes, ax2: plt.Axes): """Apply styling to a twin axes Parameters ---------- ax1 : plt.Axes Primary matplolib axis ax2 : plt.Axes Twinx matplolib axis """ ax1.tick_params(axis="x", labelrotation=self.xticks_rotation) ax1.grid(axis="both", visible=True, zorder=0) ax2.grid(visible=False) def add_label( self, fig: plt.figure, ): """Add a text label to a figure in a funny position. Parameters ---------- fig : plt.figure A matplotlib figure """ label = "OpenBB Terminal" fig.text( 0.99, 0.0420, label, fontsize=12, color="gray", alpha=0.5, horizontalalignment="right", ) # pylint: disable=import-outside-toplevel def add_cmd_source( self, fig: plt.figure, ): """Add a text label to a figure in a funny position. Parameters ---------- fig : plt.figure A matplotlib figure """ from openbb_terminal.helper_funcs import command_location if command_location: fig.text( 0.01, 0.5, command_location, rotation=90, fontsize=12, color="gray", alpha=0.5, verticalalignment="center", ) # pylint: disable=import-outside-toplevel def visualize_output(self, force_tight_layout: bool = True): """Show chart in an interactive widget.""" import openbb_terminal.feature_flags as obbff if obbff.USE_CMD_LOCATION_FIGURE: self.add_cmd_source(plt.gcf()) if obbff.USE_WATERMARK: self.add_label(plt.gcf()) if force_tight_layout: plt.tight_layout(pad=self.tight_layout_padding) if obbff.USE_ION: plt.ion() plt.show() class AllowArgsWithWhiteSpace(argparse.Action): def __call__(self, parser, namespace, values, option_string=None): setattr(namespace, self.dest, " ".join(values))