"""Platform Equity Controller.""" import os from functools import lru_cache, partial, update_wrapper from types import MethodType from typing import Any import pandas as pd from openbb_cli.config.menu_text import MenuText from openbb_cli.controllers.base_controller import BaseController from openbb_cli.controllers.utils import export_data, print_rich_table from openbb_cli.outputs.figure import is_plotly_figure from openbb_cli.session import Session session = Session() def _is_empty_result(result: object) -> bool: """Return whether a command result is ``None`` or an empty collection. Parameters ---------- result : object The command result. Returns ------- bool True for ``None`` and for empty lists, tuples, and dicts. """ return result is None or (isinstance(result, (list, tuple, dict)) and not result) def _display_dict_result(result: dict, title: str, export: bool) -> pd.DataFrame: """Display a dict command result, rendering Plotly figure JSON as a chart. Parameters ---------- result : dict The command result. title : str The command location used as the display title. export : bool Whether the result is being exported instead of displayed. Returns ------- pd.DataFrame The tabular form of the result for export, empty for figures and results that cannot be tabulated. """ if is_plotly_figure(result): if export: session.console.print( "[yellow]This command returns a chart. " "Add --raw to export its data.[/yellow]" ) else: session.output_adapter.display(data=result, title=title, chart=True) return pd.DataFrame() try: df = pd.DataFrame.from_dict(result, orient="columns") except ValueError: if not export: session.console.print(result) return pd.DataFrame() print_rich_table(df=df, show_index=True, title=title, export=export) return df @lru_cache(maxsize=1) def _OBBject() -> type: """Return ``OBBject`` lazily.""" from openbb_core.app.model.obbject import OBBject return OBBject @lru_cache(maxsize=1) def _OBBStream() -> type: """Return ``OBBStream`` lazily.""" from openbb_core.app.model.stream import OBBStream return OBBStream class DummyTranslation: """Dummy Translation for testing.""" def __init__(self): """Construct a Dummy Translation Class.""" self.paths = {} self.translators = {} class PlatformController(BaseController): """Platform Controller Base class.""" CHOICES_GENERATION = True _factory_backend: Any = None _factory_translators: dict[str, Any] | None = None _factory_paths: dict[str, str] | None = None def __init__( self, name: str, parent_path: list[str], platform_target: type | None = None, queue: list[str] | None = None, translators: dict | None = None, paths: dict[str, str] | None = None, ): """Construct a Platform based Controller.""" self.PATH = f"/{'/'.join(parent_path)}/{name}/" if parent_path else f"/{name}/" super().__init__(queue) self._name = name if ( translators is None and platform_target is None and self._factory_translators is not None ): translators = self._factory_translators if paths is None and self._factory_paths is not None: paths = self._factory_paths self._translated_target = DummyTranslation() elif platform_target is not None: from openbb import obb from openbb_cli.argparse_translator.argparse_class_processor import ( ArgparseClassProcessor, ) self._translated_target = ArgparseClassProcessor( target_class=platform_target, reference=obb.reference["paths"], # ty: ignore[not-subscriptable] ) elif translators is not None: self._translated_target = DummyTranslation() else: raise ValueError( "PlatformController needs one of: factory-injected translators, " "platform_target, or translators=...; got none." ) self.translators = ( translators if translators is not None else getattr(self._translated_target, "translators", {}) ) self.paths = ( paths if paths is not None else getattr(self._translated_target, "paths", {}) ) if self.translators: self._link_obbject_to_data_processing_commands() self._generate_commands() self._generate_sub_controllers() self.update_completer(self.choices_default) def _link_obbject_to_data_processing_commands(self): """Link data processing commands to OBBject registry.""" for _, trl in self.translators.items(): for action in trl._parser._actions: if action.dest == "data": action.choices = [ "OBB" + str(i) for i in range(len(session.obbject_registry.obbjects)) ] + [ obbject.extra["register_key"] for obbject in session.obbject_registry.obbjects if "register_key" in obbject.extra ] action.type = str action.nargs = None def _intersect_data_processing_commands(self, ns_parser): """Intersect data processing commands and change the obbject id into an actual obbject.""" if hasattr(ns_parser, "data"): if "OBB" in ns_parser.data: ns_parser.data = int(ns_parser.data.replace("OBB", "")) if (ns_parser.data in range(len(session.obbject_registry.obbjects))) or ( ns_parser.data in session.obbject_registry.obbject_keys ): obbject = session.obbject_registry.get(ns_parser.data) if obbject and isinstance(obbject, _OBBject()): setattr(ns_parser, "data", obbject.results) return ns_parser def _generate_sub_controllers(self): """Handle paths.""" for path, value in self.paths.items(): if value == "path": continue sub_menu_translators = {} choices_commands = [] sub_prefix = f"{self._name}_{path}_" for translator_name, translator in self.translators.items(): if translator_name.startswith(sub_prefix): new_name = translator_name[len(sub_prefix) :] sub_menu_translators[new_name] = translator choices_commands.append(new_name) if translator_name in self.CHOICES_COMMANDS: self.CHOICES_COMMANDS.remove(translator_name) class_name = f"{self._name.capitalize()}{path.capitalize()}Controller" SubController = type( class_name, (PlatformController,), { "CHOICES_GENERATION": True, "CHOICES_COMMANDS": choices_commands, "_factory_backend": self._factory_backend, }, ) self._generate_controller_call( controller=SubController, name=path, parent_path=self.path, translators=sub_menu_translators, ) def _generate_commands(self): """Generate commands.""" for name, translator in self.translators.items(): new_name = name.replace(f"{self._name}_", "") self._generate_command_call(name=new_name, translator=translator) def _generate_command_call(self, name, translator): """Generate command call.""" def method(self, other_args: list[str], translator=translator): """Call the translator.""" parser = translator.parser if ns_parser := self.parse_known_args_and_warn( parser=parser, other_args=other_args, export_allowed="raw_data_and_figures", ): try: ns_parser = self._intersect_data_processing_commands(ns_parser) export = hasattr(ns_parser, "export") and ns_parser.export store_obbject = ( hasattr(ns_parser, "register_obbject") and ns_parser.register_obbject ) obbject = translator.execute_func(parsed_args=ns_parser) df: pd.DataFrame = pd.DataFrame() title = f"{self.PATH}{translator.func.__name__}" if obbject: if isinstance(obbject, list): obbject = _OBBject()(results=obbject) if isinstance(obbject, _OBBStream()): self._follow_stream(obbject) elif isinstance(obbject, _OBBject()): if ( session.max_obbjects_exceeded() and obbject.results and store_obbject ): session.obbject_registry.remove() session.console.print( "[yellow]Maximum number of OBBjects reached. The oldest entry was removed.[yellow]" ) obbject.extra["command"] = f"{title} {' '.join(other_args)}" if ( hasattr(ns_parser, "register_key") and ns_parser.register_key ): if ( ns_parser.register_key not in session.obbject_registry.obbject_keys ): obbject.extra["register_key"] = str( ns_parser.register_key ) else: session.console.print( f"[yellow]Key `{ns_parser.register_key}` already exists in the registry." "The `OBBject` was kept without the key.[/yellow]" ) if store_obbject: register_result = session.obbject_registry.register( obbject ) self._link_obbject_to_data_processing_commands() self.update_completer(self.choices_default) if ( session.settings.SHOW_MSG_OBBJECT_REGISTRY and register_result ): session.console.print( "Added `OBBject` to cached results." ) chart = hasattr(ns_parser, "chart") and ns_parser.chart if not export: try: session.output_adapter.display( data=obbject, title=title, export=export, chart=chart, ) except Exception as e: session.console.print( f"[red]Display error: {e}[/red]" ) if hasattr(obbject, "model_dump"): results = obbject.model_dump( exclude_unset=True, exclude_none=True ).get("results") session.console.print(results) elif isinstance(obbject, dict): df = _display_dict_result(obbject, title, export) elif not isinstance(obbject, _OBBject()): session.console.print(obbject) elif not export and _is_empty_result(obbject): session.console.print("[yellow]No results.[/yellow]") if export and not df.empty: sheet_name = getattr(ns_parser, "sheet_name", None) if sheet_name and isinstance(sheet_name, list): sheet_name = sheet_name[0] fig = None if hasattr(ns_parser, "chart") and ns_parser.chart: from contextlib import suppress with suppress(Exception): fig = obbject.chart.fig if obbject.chart else None export_data( export_type=",".join(ns_parser.export), dir_path=os.path.dirname(os.path.abspath(__file__)), func_name=translator.func.__name__, df=df, sheet_name=sheet_name, figure=fig, ) elif export and df.empty: session.console.print("[yellow]No data to export.[/yellow]") except Exception as e: session.console.print(f"[red]{e}[/]\n") return bound_method = MethodType(method, self) bound_method = update_wrapper( partial(bound_method, translator=translator), method ) setattr(self, f"call_{name}", bound_method) def _follow_stream(self, stream) -> None: """Follow a streaming (e.g. SSE) result in the foreground. Streams chunks to the console on a background thread and blocks until the stream ends or the user presses Ctrl+C, then stops it cleanly. """ session.console.print( f"[cyan]Streaming {stream.media_type or 'data'} — press Ctrl+C to stop.[/cyan]" ) stream.start() try: stream.wait() except KeyboardInterrupt: session.console.print("\n[yellow]Stream interrupted.[/yellow]") finally: stream.stop() session.console.print("[cyan]Stream stopped.[/cyan]") def _generate_controller_call(self, controller, name, parent_path, translators): """Generate controller call.""" def method(self, _, controller, name, parent_path, translators): """Call the controller.""" self.queue = self.load_class( class_ins=controller, name=name, parent_path=parent_path, translators=translators, queue=self.queue, ) bound_method = MethodType(method, self) bound_method = update_wrapper( partial( bound_method, name=name, parent_path=parent_path, translators=translators, controller=controller, ), method, ) setattr(self, f"call_{name}", bound_method) def _get_reference_paths(self) -> dict[str, dict[str, Any]]: """Return the reference path-description dict.""" if self._factory_backend is not None: return self._factory_backend.reference_paths from openbb_cli.backend import LocalBackend return LocalBackend().reference_paths def _get_reference_routers(self) -> dict[str, dict[str, Any]]: """Return the reference router-description dict.""" if self._factory_backend is not None: return self._factory_backend.reference_routers from openbb_cli.backend import LocalBackend return LocalBackend().reference_routers def _get_command_description(self, command: str) -> str: """Get command description.""" command_description = ( self._get_reference_paths() .get(f"{self.PATH}{command}", {}) .get("description", "") ) if not command_description: trl = self.translators.get( f"{self._name}_{command}" ) or self.translators.get(command) if trl and hasattr(trl, "parser"): command_description = trl.parser.description return (command_description or "").split(".")[0].lower() def _get_menu_description(self, menu: str) -> str: """Get menu description.""" def _get_sub_menu_commands(): """Get sub menu commands.""" sub_path = f"{self.PATH[1:].replace('/', '_')}{menu}" commands = [] for trl in self.translators: if sub_path in trl: commands.append(trl.replace(f"{sub_path}_", "")) return commands menu_description = ( self._get_reference_routers() .get(f"{self.PATH}{menu}", {}) .get("description", "") ) or "" if menu_description: return menu_description.split(".")[0].lower() return ", ".join(_get_sub_menu_commands()) def print_help(self): """Print help.""" mt = MenuText(self.PATH) if self.CHOICES_MENUS: for menu in self.CHOICES_MENUS: description = self._get_menu_description(menu) mt.add_menu(name=menu, description=description) if self.CHOICES_COMMANDS: mt.add_raw("\n") if self.CHOICES_COMMANDS: for command in self.CHOICES_COMMANDS: command_description = self._get_command_description(command) mt.add_cmd( name=command.replace(f"{self._name}_", ""), description=command_description, ) if session.obbject_registry.obbjects: mt.add_info("\nCached Results") for key, value in list(session.obbject_registry.all.items())[ : session.settings.N_TO_DISPLAY_OBBJECT_REGISTRY ]: command = (value.get("extra") or {}).get("command", "") mt.add_raw( f"[yellow]OBB{key}[/yellow]: {command}", left_spacing=True, ) session.console.print(text=mt.menu_text, menu=self.PATH) if mt.warnings: session.console.print("") for w in mt.warnings: w_str = str(w).replace("{", "").replace("}", "").replace("'", "") session.console.print(f"[yellow]{w_str}[/yellow]") session.console.print("")