mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-10-27 04:31:13 +00:00
[Feature] OpenBB Plugin Extensions (#7247)
* introduce openbb plugins * also enforce user setting for on_command_output * better handling from Views output to the charting extension * pass a copy of kwargs to in CommandRunner and make sure not to overwrite **kwargs from non-provider endpoints
This commit is contained in:
@@ -7,8 +7,11 @@ from inspect import Parameter, Signature, signature
|
|||||||
from typing import Annotated, Any, TypeVar
|
from typing import Annotated, Any, TypeVar
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Header
|
from fastapi import APIRouter, Depends, Header
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.routing import APIRoute
|
from fastapi.routing import APIRoute
|
||||||
from openbb_core.app.command_runner import CommandRunner
|
from openbb_core.app.command_runner import CommandRunner
|
||||||
|
from openbb_core.app.model.abstract.error import OpenBBError
|
||||||
from openbb_core.app.model.command_context import CommandContext
|
from openbb_core.app.model.command_context import CommandContext
|
||||||
from openbb_core.app.model.obbject import OBBject
|
from openbb_core.app.model.obbject import OBBject
|
||||||
from openbb_core.app.model.user_settings import UserSettings
|
from openbb_core.app.model.user_settings import UserSettings
|
||||||
@@ -140,7 +143,7 @@ def validate_output(c_out: OBBject) -> OBBject:
|
|||||||
|
|
||||||
def exclude_fields_from_api(key: str, value: Any):
|
def exclude_fields_from_api(key: str, value: Any):
|
||||||
type_ = type(value)
|
type_ = type(value)
|
||||||
field = c_out.model_fields.get(key, None)
|
field = getattr(type(c_out), "model_fields", {}).get(key, None)
|
||||||
json_schema_extra = field.json_schema_extra if field else None
|
json_schema_extra = field.json_schema_extra if field else None
|
||||||
|
|
||||||
# case where 1st layer field needs to be excluded
|
# case where 1st layer field needs to be excluded
|
||||||
@@ -198,7 +201,9 @@ def build_api_wrapper(
|
|||||||
route.response_model = None
|
route.response_model = None
|
||||||
|
|
||||||
@wraps(wrapped=func)
|
@wraps(wrapped=func)
|
||||||
async def wrapper(*args: tuple[Any], **kwargs: dict[str, Any]) -> OBBject:
|
async def wrapper(
|
||||||
|
*args: tuple[Any], **kwargs: dict[str, Any]
|
||||||
|
) -> OBBject | JSONResponse:
|
||||||
user_settings: UserSettings = UserSettings.model_validate(
|
user_settings: UserSettings = UserSettings.model_validate(
|
||||||
kwargs.pop(
|
kwargs.pop(
|
||||||
"__authenticated_user_settings",
|
"__authenticated_user_settings",
|
||||||
@@ -213,7 +218,7 @@ def build_api_wrapper(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if defaults:
|
if defaults:
|
||||||
_provider = defaults.pop("provider", None)
|
_ = defaults.pop("provider", None)
|
||||||
standard_params = getattr(
|
standard_params = getattr(
|
||||||
kwargs.pop("standard_params", None), "__dict__", {}
|
kwargs.pop("standard_params", None), "__dict__", {}
|
||||||
)
|
)
|
||||||
@@ -243,6 +248,23 @@ def build_api_wrapper(
|
|||||||
execute = partial(command_runner.run, path, user_settings)
|
execute = partial(command_runner.run, path, user_settings)
|
||||||
output = await execute(*args, **kwargs)
|
output = await execute(*args, **kwargs)
|
||||||
|
|
||||||
|
# This is where we check for `on_command_output` extensions
|
||||||
|
mutated_output = getattr(output, "_extension_modified", False)
|
||||||
|
results_only = getattr(output, "_results_only", False)
|
||||||
|
try:
|
||||||
|
if results_only is True:
|
||||||
|
content = output.model_dump(exclude_unset=True).get("results", [])
|
||||||
|
return JSONResponse(content=jsonable_encoder(content), status_code=200)
|
||||||
|
|
||||||
|
if (mutated_output and isinstance(output, OBBject)) or (
|
||||||
|
isinstance(output, OBBject) and no_validate
|
||||||
|
):
|
||||||
|
return JSONResponse(content=jsonable_encoder(output), status_code=200)
|
||||||
|
except Exception as exc: # pylint: disable=W0703
|
||||||
|
raise OpenBBError(
|
||||||
|
f"Error serializing output for an extension-modified endpoint {path}: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
if isinstance(output, OBBject) and not no_validate:
|
if isinstance(output, OBBject) and not no_validate:
|
||||||
return validate_output(output)
|
return validate_output(output)
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ from collections.abc import Callable
|
|||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from dataclasses import asdict, is_dataclass
|
from dataclasses import asdict, is_dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from inspect import Parameter, signature
|
from inspect import Parameter, iscoroutinefunction, signature
|
||||||
from sys import exc_info
|
from sys import exc_info
|
||||||
from time import perf_counter_ns
|
from time import perf_counter_ns
|
||||||
from typing import TYPE_CHECKING, Any, Optional
|
from typing import TYPE_CHECKING, Any, Optional
|
||||||
from warnings import catch_warnings, showwarning, warn
|
from warnings import catch_warnings, showwarning, warn
|
||||||
|
|
||||||
|
from openbb_core.app.extension_loader import ExtensionLoader
|
||||||
from openbb_core.app.model.abstract.error import OpenBBError
|
from openbb_core.app.model.abstract.error import OpenBBError
|
||||||
from openbb_core.app.model.abstract.warning import OpenBBWarning, cast_warning
|
from openbb_core.app.model.abstract.warning import OpenBBWarning, cast_warning
|
||||||
from openbb_core.app.model.metadata import Metadata
|
from openbb_core.app.model.metadata import Metadata
|
||||||
@@ -272,6 +273,7 @@ class StaticCommandRunner:
|
|||||||
|
|
||||||
if chart_params:
|
if chart_params:
|
||||||
kwargs.update(chart_params)
|
kwargs.update(chart_params)
|
||||||
|
|
||||||
obbject.charting.show(render=False, **kwargs) # type: ignore[attr-defined]
|
obbject.charting.show(render=False, **kwargs) # type: ignore[attr-defined]
|
||||||
except Exception as e: # pylint: disable=broad-exception-caught
|
except Exception as e: # pylint: disable=broad-exception-caught
|
||||||
if Env().DEBUG_MODE:
|
if Env().DEBUG_MODE:
|
||||||
@@ -311,6 +313,9 @@ class StaticCommandRunner:
|
|||||||
# in the charting extension then we add it there. This way we can remove
|
# in the charting extension then we add it there. This way we can remove
|
||||||
# the chart parameter from the commands.py and package_builder, it will be
|
# the chart parameter from the commands.py and package_builder, it will be
|
||||||
# added to the function signature in the router decorator
|
# added to the function signature in the router decorator
|
||||||
|
# If the ProviderInterface is not in use, we need to pass a copy of the
|
||||||
|
# kwargs dictionary before it is validated, otherwise we lose those items.
|
||||||
|
kwargs_copy = deepcopy(kwargs)
|
||||||
chart = kwargs.pop("chart", False)
|
chart = kwargs.pop("chart", False)
|
||||||
|
|
||||||
kwargs = ParametersBuilder.build(
|
kwargs = ParametersBuilder.build(
|
||||||
@@ -348,7 +353,21 @@ class StaticCommandRunner:
|
|||||||
extra_params
|
extra_params
|
||||||
)
|
)
|
||||||
if chart and obbject.results:
|
if chart and obbject.results:
|
||||||
cls._chart(obbject, **kwargs)
|
if "extra_params" not in kwargs_copy:
|
||||||
|
kwargs_copy["extra_params"] = {}
|
||||||
|
# Restore any kwargs passed that were removed by the ParametersBuilder
|
||||||
|
for k in kwargs_copy.copy():
|
||||||
|
if k == "chart":
|
||||||
|
kwargs_copy.pop("chart", None)
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
not extra_params or k not in extra_params
|
||||||
|
) and k != "extra_params":
|
||||||
|
kwargs_copy["extra_params"][k] = kwargs_copy.pop(
|
||||||
|
k, None
|
||||||
|
)
|
||||||
|
|
||||||
|
cls._chart(obbject, **kwargs_copy)
|
||||||
|
|
||||||
raised_warnings = warning_list if warning_list else []
|
raised_warnings = warning_list if warning_list else []
|
||||||
finally:
|
finally:
|
||||||
@@ -429,8 +448,79 @@ class StaticCommandRunner:
|
|||||||
raise OpenBBError(e) from e
|
raise OpenBBError(e) from e
|
||||||
warn(str(e), OpenBBWarning)
|
warn(str(e), OpenBBWarning)
|
||||||
|
|
||||||
|
try:
|
||||||
|
cls._trigger_command_output_callbacks(route, obbject)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if Env().DEBUG_MODE:
|
||||||
|
raise OpenBBError(e) from e
|
||||||
|
warn(str(e), OpenBBWarning)
|
||||||
|
|
||||||
return obbject
|
return obbject
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _trigger_command_output_callbacks(cls, route: str, obbject: OBBject) -> None:
|
||||||
|
"""Trigger command output callbacks for extensions."""
|
||||||
|
loader = ExtensionLoader()
|
||||||
|
callbacks = loader.on_command_output_callbacks
|
||||||
|
results_only = False
|
||||||
|
|
||||||
|
# For each extension registered for all routes or the specific route,
|
||||||
|
# we call its accessor on the OBBject.
|
||||||
|
# We check if the accessor is immutable or not to decide whether to pass
|
||||||
|
# a copy of the OBBject or the original one.
|
||||||
|
# We set the _extension_modified attribute to True if any extension
|
||||||
|
# mutates the OBBject so we can pass this information to the interface.
|
||||||
|
# We also set the _results_only attribute to True if any extension
|
||||||
|
# indicates that only results should be returned.
|
||||||
|
if "*" in callbacks:
|
||||||
|
for ext in callbacks["*"]:
|
||||||
|
if ext.results_only is True:
|
||||||
|
results_only = True
|
||||||
|
if ext.immutable is True:
|
||||||
|
if hasattr(obbject, ext.name):
|
||||||
|
obbject_copy = deepcopy(obbject)
|
||||||
|
accessor = getattr(obbject_copy, ext.name)
|
||||||
|
if iscoroutinefunction(accessor):
|
||||||
|
run_async(accessor)
|
||||||
|
elif callable(accessor):
|
||||||
|
accessor()
|
||||||
|
elif ext.immutable is False:
|
||||||
|
if ext.results_only is True:
|
||||||
|
results_only = True
|
||||||
|
if hasattr(obbject, ext.name):
|
||||||
|
accessor = getattr(obbject, ext.name)
|
||||||
|
if iscoroutinefunction(accessor):
|
||||||
|
run_async(accessor)
|
||||||
|
elif callable(accessor):
|
||||||
|
accessor()
|
||||||
|
setattr(obbject, "_extension_modified", True)
|
||||||
|
|
||||||
|
if route in callbacks:
|
||||||
|
for ext in callbacks[route]:
|
||||||
|
if ext.results_only is True:
|
||||||
|
results_only = True
|
||||||
|
|
||||||
|
if ext.immutable is True:
|
||||||
|
if hasattr(obbject, ext.name):
|
||||||
|
obbject_copy = deepcopy(obbject)
|
||||||
|
accessor = getattr(obbject_copy, ext.name)
|
||||||
|
if iscoroutinefunction(accessor):
|
||||||
|
run_async(accessor)
|
||||||
|
elif callable(accessor):
|
||||||
|
accessor()
|
||||||
|
elif ext.immutable is False and hasattr(obbject, ext.name):
|
||||||
|
accessor = getattr(obbject, ext.name)
|
||||||
|
if iscoroutinefunction(accessor):
|
||||||
|
run_async(accessor)
|
||||||
|
elif callable(accessor):
|
||||||
|
accessor()
|
||||||
|
setattr(obbject, "_extension_modified", True)
|
||||||
|
|
||||||
|
if results_only is True:
|
||||||
|
setattr(obbject, "_results_only", True)
|
||||||
|
setattr(obbject, "_extension_modified", True)
|
||||||
|
|
||||||
|
|
||||||
class CommandRunner:
|
class CommandRunner:
|
||||||
"""Command runner."""
|
"""Command runner."""
|
||||||
|
|||||||
@@ -49,6 +49,23 @@ class ExtensionLoader(metaclass=SingletonMeta):
|
|||||||
self._obbject_objects: dict[str, Extension] = {}
|
self._obbject_objects: dict[str, Extension] = {}
|
||||||
self._core_objects: dict[str, Router] = {}
|
self._core_objects: dict[str, Router] = {}
|
||||||
self._provider_objects: dict[str, Provider] = {}
|
self._provider_objects: dict[str, Provider] = {}
|
||||||
|
self._on_command_output_callbacks: dict[str, list[Extension]] = {}
|
||||||
|
self._register_command_output_callbacks()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def on_command_output_callbacks(self) -> dict[str, list[Extension]]:
|
||||||
|
"""Return the on command output callbacks."""
|
||||||
|
return self._on_command_output_callbacks
|
||||||
|
|
||||||
|
def _register_command_output_callbacks(self) -> None:
|
||||||
|
"""Register extensions that act on command output."""
|
||||||
|
for ext in self.obbject_objects.values():
|
||||||
|
if ext.on_command_output:
|
||||||
|
paths = ext.command_output_paths or ["*"]
|
||||||
|
for path in paths:
|
||||||
|
if path not in self._on_command_output_callbacks:
|
||||||
|
self._on_command_output_callbacks[path] = []
|
||||||
|
self._on_command_output_callbacks[path].append(ext)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def obbject_entry_points(self) -> EntryPoints:
|
def obbject_entry_points(self) -> EntryPoints:
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"""OpenBB Core App Abstract Model Tagged."""
|
"""OpenBB Core App Abstract Model Tagged."""
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from uuid_extensions import uuid7str # type: ignore
|
from uuid_extensions import uuid7str
|
||||||
|
|
||||||
|
|
||||||
class Tagged(BaseModel):
|
class Tagged(BaseModel):
|
||||||
"""Model for Tagged."""
|
"""Model for Tagged."""
|
||||||
|
|
||||||
id: str = Field(default_factory=uuid7str, alias="_id")
|
id: str = Field(default_factory=uuid7str)
|
||||||
|
|||||||
@@ -8,14 +8,19 @@ class Extension:
|
|||||||
"""
|
"""
|
||||||
Serves as OBBject extension entry point and must be created by each extension package.
|
Serves as OBBject extension entry point and must be created by each extension package.
|
||||||
|
|
||||||
See https://docs.openbb.co/platform/development/developer-guidelines/obbject_extensions.
|
See https://docs.openbb.co/developer/extension_types/obbject for more information.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=R0917
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
credentials: list[str] | None = None,
|
credentials: list[str] | None = None,
|
||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
|
on_command_output: bool = False,
|
||||||
|
command_output_paths: list[str] | None = None,
|
||||||
|
immutable: bool = True,
|
||||||
|
results_only: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the extension.
|
"""Initialize the extension.
|
||||||
|
|
||||||
@@ -23,20 +28,76 @@ class Extension:
|
|||||||
----------
|
----------
|
||||||
name : str
|
name : str
|
||||||
Name of the extension.
|
Name of the extension.
|
||||||
credentials : Optional[List[str]], optional
|
credentials : list[str], optional
|
||||||
List of required credentials, by default None
|
List of required credentials, by default None
|
||||||
description: Optional[str]
|
description: Optional[str]
|
||||||
Extension description.
|
Extension description.
|
||||||
|
on_command_output : bool, optional
|
||||||
|
Whether the extension acts on command output, by default False
|
||||||
|
command_output_paths : list[str], optional
|
||||||
|
List of endpoint paths the extension acts on, where None means all, by default None.
|
||||||
|
immutable : bool, optional
|
||||||
|
Whether the function output is immutable, by default True.
|
||||||
|
results_only : bool, optional
|
||||||
|
Whether the extension returns only the results instead of the OBBject, by default False.
|
||||||
"""
|
"""
|
||||||
|
# pylint: disable=import-outside-toplevel
|
||||||
|
from openbb_core.app.service.system_service import SystemService
|
||||||
|
|
||||||
self.name = name
|
self.name = name
|
||||||
self.credentials = credentials or []
|
self.credentials = credentials or []
|
||||||
self.description = description
|
self.description = description
|
||||||
|
self.on_command_output = on_command_output
|
||||||
|
self.command_output_paths = command_output_paths or []
|
||||||
|
self.immutable = immutable
|
||||||
|
self.results_only = results_only
|
||||||
|
|
||||||
|
# This must be explicitly enabled.
|
||||||
|
if self.on_command_output is False and (
|
||||||
|
self.command_output_paths
|
||||||
|
or self.results_only is True
|
||||||
|
or self.immutable is False
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"OBBject Extension Error -> 'on_command_output' must be set as True when"
|
||||||
|
+ " 'command_output_paths', 'results_only' or 'immutable' is set.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# The user must explicitly enable OBBject extensions that act on command output.
|
||||||
|
if (
|
||||||
|
self.on_command_output
|
||||||
|
and not SystemService().system_settings.allow_on_command_output
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
"OBBject Extension Error -> \n\n"
|
||||||
|
+ "An OBBject extension that acts on command output is installed "
|
||||||
|
+ "but has not been enabled in `system_settings.json`.\n\n"
|
||||||
|
+ "Set `allow_on_command_output` to True to enable it.\n"
|
||||||
|
+ "Or, set the environment variable `OPENBB_ALLOW_ON_COMMAND_OUTPUT` to True."
|
||||||
|
+ "\n\nProceed with caution as this may have security implications.\n\n"
|
||||||
|
+ "Ensure the extension is installed from a trusted source.\n\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
# The user must explicitly enable OBBject extensions that modify output.
|
||||||
|
if (
|
||||||
|
self.on_command_output
|
||||||
|
and self.immutable is False
|
||||||
|
and not SystemService().system_settings.allow_mutable_extensions
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
"OBBject Extension Error -> \n\n"
|
||||||
|
+ "An OBBject extension that modifies the output is installed "
|
||||||
|
+ "but has not been enabled in `system_settings.json`.\n\n"
|
||||||
|
+ "Set `allow_mutable_extensions` to True to enable it.\n"
|
||||||
|
+ "Or, set the environment variable `OPENBB_ALLOW_MUTABLE_EXTENSIONS` to True."
|
||||||
|
+ "\n\nProceed with caution as this may have security implications.\n\n"
|
||||||
|
+ "Ensure the extension is installed from a trusted source.\n\n",
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def obbject_accessor(self) -> Callable:
|
def obbject_accessor(self) -> Callable:
|
||||||
"""Extend an OBBject, inspired by pandas."""
|
"""Extend an OBBject, inspired by pandas."""
|
||||||
# pylint: disable=import-outside-toplevel
|
# pylint: disable=import-outside-toplevel
|
||||||
# Avoid circular imports
|
|
||||||
|
|
||||||
from openbb_core.app.model.obbject import OBBject
|
from openbb_core.app.model.obbject import OBBject
|
||||||
|
|
||||||
@@ -55,8 +116,8 @@ class Extension:
|
|||||||
UserWarning,
|
UserWarning,
|
||||||
)
|
)
|
||||||
setattr(cls, name, CachedAccessor(name, accessor))
|
setattr(cls, name, CachedAccessor(name, accessor))
|
||||||
# pylint: disable=protected-access
|
|
||||||
cls.accessors.add(name)
|
cls.accessors.add(name)
|
||||||
|
|
||||||
return accessor
|
return accessor
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class OBBject(Tagged, Generic[T]):
|
|||||||
default_factory=dict,
|
default_factory=dict,
|
||||||
description="Extra info.",
|
description="Extra info.",
|
||||||
)
|
)
|
||||||
_route: str = PrivateAttr(
|
_route: str | None = PrivateAttr(
|
||||||
default=None,
|
default=None,
|
||||||
)
|
)
|
||||||
_standard_params: dict[str, Any] | None = PrivateAttr(
|
_standard_params: dict[str, Any] | None = PrivateAttr(
|
||||||
@@ -178,9 +178,9 @@ class OBBject(Tagged, Generic[T]):
|
|||||||
|
|
||||||
# BaseModel
|
# BaseModel
|
||||||
if isinstance(res, BaseModel):
|
if isinstance(res, BaseModel):
|
||||||
res_dict = res.model_dump(
|
res_dict = res.model_dump( # pylint: disable=no-member
|
||||||
exclude_unset=True, exclude_none=True
|
exclude_unset=True, exclude_none=True
|
||||||
) # pylint: disable=no-member
|
)
|
||||||
# Model is serialized as a dict[str, list] or list[dict]
|
# Model is serialized as a dict[str, list] or list[dict]
|
||||||
if (
|
if (
|
||||||
(
|
(
|
||||||
@@ -322,13 +322,16 @@ class OBBject(Tagged, Generic[T]):
|
|||||||
orient == "list"
|
orient == "list"
|
||||||
and isinstance(self.results, dict)
|
and isinstance(self.results, dict)
|
||||||
and all(
|
and all(
|
||||||
isinstance(value, dict) for value in self.results.values()
|
isinstance(value, dict)
|
||||||
) # pylint: disable=no-member
|
for value in self.results.values() # pylint: disable=no-member
|
||||||
|
)
|
||||||
):
|
):
|
||||||
df = df.T
|
df = df.T
|
||||||
results = df.to_dict(orient=orient)
|
results: dict | list = df.to_dict(orient=orient)
|
||||||
|
|
||||||
if isinstance(results, dict) and orient == "list" and "index" in results:
|
if isinstance(results, dict) and orient == "list" and "index" in results:
|
||||||
del results["index"]
|
del results["index"]
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def to_llm(self) -> dict[Hashable, Any] | list[dict[Hashable, Any]]:
|
def to_llm(self) -> dict[Hashable, Any] | list[dict[Hashable, Any]]:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from openbb_core.app.model.abstract.tagged import Tagged
|
|||||||
from openbb_core.app.model.api_settings import APISettings
|
from openbb_core.app.model.api_settings import APISettings
|
||||||
from openbb_core.app.model.python_settings import PythonSettings
|
from openbb_core.app.model.python_settings import PythonSettings
|
||||||
from openbb_core.app.version import CORE_VERSION, VERSION
|
from openbb_core.app.version import CORE_VERSION, VERSION
|
||||||
|
from openbb_core.env import Env
|
||||||
from pydantic import ConfigDict, Field, field_validator, model_validator
|
from pydantic import ConfigDict, Field, field_validator, model_validator
|
||||||
|
|
||||||
|
|
||||||
@@ -54,6 +55,8 @@ class SystemSettings(Tagged):
|
|||||||
debug_mode: bool = False
|
debug_mode: bool = False
|
||||||
test_mode: bool = False
|
test_mode: bool = False
|
||||||
headless: bool = False
|
headless: bool = False
|
||||||
|
allow_mutable_extensions: bool = getattr(Env(), "ALLOW_MUTABLE_EXTENSIONS", False)
|
||||||
|
allow_on_command_output: bool = getattr(Env(), "ALLOW_ON_COMMAND_OUTPUT", False)
|
||||||
|
|
||||||
model_config = ConfigDict(validate_assignment=True, frozen=True)
|
model_config = ConfigDict(validate_assignment=True, frozen=True)
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ class SystemService(metaclass=SingletonMeta):
|
|||||||
"python_settings",
|
"python_settings",
|
||||||
"debug_mode",
|
"debug_mode",
|
||||||
"logging_suppress",
|
"logging_suppress",
|
||||||
|
"allow_mutable_extensions",
|
||||||
|
"allow_on_command_output",
|
||||||
}
|
}
|
||||||
|
|
||||||
PRO_VALIDATION_HASH = "300ac59fdcc8f899e0bc5c18cda8652220735da1a00e2af365efe9d8e5fe8306" # pragma: allowlist secret
|
PRO_VALIDATION_HASH = "300ac59fdcc8f899e0bc5c18cda8652220735da1a00e2af365efe9d8e5fe8306" # pragma: allowlist secret
|
||||||
|
|||||||
@@ -43,9 +43,18 @@ class Container:
|
|||||||
kwargs["extra_params"][k] = v
|
kwargs["extra_params"][k] = v
|
||||||
|
|
||||||
obbject = self._command_runner.sync_run(*args, **kwargs)
|
obbject = self._command_runner.sync_run(*args, **kwargs)
|
||||||
|
|
||||||
|
results_only = getattr(obbject, "_results_only", False)
|
||||||
|
|
||||||
|
if results_only is True:
|
||||||
|
content = obbject.model_dump(exclude_unset=True).get("results", [])
|
||||||
|
return content
|
||||||
|
|
||||||
output_type = self._command_runner.user_settings.preferences.output_type
|
output_type = self._command_runner.user_settings.preferences.output_type
|
||||||
|
|
||||||
if output_type == "OBBject":
|
if output_type == "OBBject":
|
||||||
return obbject
|
return obbject
|
||||||
|
|
||||||
return getattr(obbject, "to_" + output_type)()
|
return getattr(obbject, "to_" + output_type)()
|
||||||
|
|
||||||
def _check_credentials(self, provider: str) -> bool | None:
|
def _check_credentials(self, provider: str) -> bool | None:
|
||||||
|
|||||||
@@ -54,9 +54,16 @@ class Env(metaclass=SingletonMeta):
|
|||||||
return self.str2bool(self._environ.get("OPENBB_DEV_MODE", False))
|
return self.str2bool(self._environ.get("OPENBB_DEV_MODE", False))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def HUB_BACKEND(self) -> str:
|
def ALLOW_MUTABLE_EXTENSIONS(self) -> bool:
|
||||||
"""Hub backend: sets the backend for the OpenBB Hub."""
|
"""Allow mutable extensions: enables extensions that modify OBBject output."""
|
||||||
return self._environ.get("OPENBB_HUB_BACKEND", "https://payments.openbb.co")
|
return self.str2bool(
|
||||||
|
self._environ.get("OPENBB_ALLOW_MUTABLE_EXTENSIONS", False)
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ALLOW_ON_COMMAND_OUTPUT(self) -> bool:
|
||||||
|
"""Allow on command output: enables extensions that act on command output."""
|
||||||
|
return self.str2bool(self._environ.get("OPENBB_ALLOW_ON_COMMAND_OUTPUT", False))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def str2bool(value) -> bool:
|
def str2bool(value) -> bool:
|
||||||
|
|||||||
121
openbb_platform/core/tests/app/model/test_extension.py
Normal file
121
openbb_platform/core/tests/app/model/test_extension.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""Test Extension model and its functionalities."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from openbb_core.app.model.extension import CachedAccessor, Extension
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_raises_if_invalid_configuration():
|
||||||
|
# on_command_output False but other flags set -> should raise
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
Extension(
|
||||||
|
name="bad",
|
||||||
|
on_command_output=False,
|
||||||
|
command_output_paths=["/some/path"],
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
Extension(name="bad2", on_command_output=False, results_only=True)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
Extension(name="bad3", on_command_output=False, immutable=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_accessor_registers_and_warns_on_override():
|
||||||
|
class Dummy:
|
||||||
|
accessors = set()
|
||||||
|
|
||||||
|
def accessor_factory(obj):
|
||||||
|
return SimpleNamespace(called_with=obj)
|
||||||
|
|
||||||
|
# Register new accessor
|
||||||
|
decorator = Extension.register_accessor("foo", Dummy)
|
||||||
|
returned = decorator(accessor_factory)
|
||||||
|
assert returned is accessor_factory # decorator returns the original accessor
|
||||||
|
assert hasattr(Dummy, "foo")
|
||||||
|
assert "foo" in Dummy.accessors
|
||||||
|
# descriptor instance is stored in the class __dict__; accessing via the
|
||||||
|
# class returns the result of descriptor.__get__, so inspect __dict__
|
||||||
|
assert isinstance(Dummy.__dict__["foo"], CachedAccessor)
|
||||||
|
|
||||||
|
# If attribute already exists, registration should warn
|
||||||
|
Dummy.existing = "I exist" # type: ignore
|
||||||
|
with pytest.warns(UserWarning):
|
||||||
|
Extension.register_accessor("existing", Dummy)(accessor_factory)
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
if "foo" in Dummy.accessors:
|
||||||
|
Dummy.accessors.remove("foo")
|
||||||
|
if hasattr(Dummy, "foo"):
|
||||||
|
delattr(Dummy, "foo")
|
||||||
|
if hasattr(Dummy, "existing"):
|
||||||
|
delattr(Dummy, "existing")
|
||||||
|
|
||||||
|
|
||||||
|
def test_cached_accessor_descriptor_behavior_and_caching():
|
||||||
|
class Dummy:
|
||||||
|
accessors = set()
|
||||||
|
|
||||||
|
def __init__(self, name="n"):
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
def make_accessor(obj):
|
||||||
|
# returns an object instance to be cached on the target instance
|
||||||
|
return SimpleNamespace(result=f"accessed-{obj.name}")
|
||||||
|
|
||||||
|
Extension.register_accessor("myext", Dummy)(make_accessor)
|
||||||
|
|
||||||
|
inst = Dummy("alice")
|
||||||
|
# Accessing on instance should call accessor and cache the result on instance
|
||||||
|
returned = inst.myext # type: ignore # pylint: disable=E1101
|
||||||
|
assert isinstance(returned, SimpleNamespace)
|
||||||
|
assert returned.result == "accessed-alice"
|
||||||
|
# The attribute should now exist on the instance (cached)
|
||||||
|
assert getattr(inst, "myext") is returned
|
||||||
|
|
||||||
|
# The descriptor object itself lives in the class __dict__
|
||||||
|
cls_attr = Dummy.__dict__["myext"]
|
||||||
|
assert isinstance(cls_attr, CachedAccessor)
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
Dummy.accessors.discard("myext")
|
||||||
|
if hasattr(Dummy, "myext"):
|
||||||
|
delattr(Dummy, "myext")
|
||||||
|
|
||||||
|
|
||||||
|
def test_obbject_accessor_registers_on_mocked_obbject_module(monkeypatch):
|
||||||
|
# Create a fake module to be imported in the property
|
||||||
|
mod_name = "openbb_core.app.model.obbject"
|
||||||
|
fake_mod = types.ModuleType(mod_name)
|
||||||
|
|
||||||
|
class FakeOBBject:
|
||||||
|
accessors = set()
|
||||||
|
|
||||||
|
fake_mod.OBBject = FakeOBBject # type: ignore
|
||||||
|
|
||||||
|
# Install fake module into sys.modules so import inside property picks it up
|
||||||
|
monkeypatch.setitem(sys.modules, mod_name, fake_mod)
|
||||||
|
|
||||||
|
ext = Extension(name="ext_name")
|
||||||
|
decorator = (
|
||||||
|
ext.obbject_accessor
|
||||||
|
) # should return the register decorator for FakeOBBject
|
||||||
|
|
||||||
|
def sample_accessor(obj):
|
||||||
|
return SimpleNamespace(tag=f"ext-{getattr(obj,'_marker',None)}")
|
||||||
|
|
||||||
|
decorator(sample_accessor)
|
||||||
|
|
||||||
|
assert "ext_name" in FakeOBBject.accessors
|
||||||
|
assert hasattr(FakeOBBject, "ext_name")
|
||||||
|
# descriptor instance must be in the class __dict__
|
||||||
|
assert isinstance(FakeOBBject.__dict__["ext_name"], CachedAccessor)
|
||||||
|
|
||||||
|
# cleanup
|
||||||
|
FakeOBBject.accessors.discard("ext_name")
|
||||||
|
if hasattr(FakeOBBject, "ext_name"):
|
||||||
|
delattr(FakeOBBject, "ext_name")
|
||||||
|
monkeypatch.delitem(sys.modules, mod_name)
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from inspect import Parameter
|
from inspect import Parameter
|
||||||
|
from types import SimpleNamespace
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -15,6 +16,7 @@ from openbb_core.app.command_runner import (
|
|||||||
)
|
)
|
||||||
from openbb_core.app.model.abstract.warning import OpenBBWarning
|
from openbb_core.app.model.abstract.warning import OpenBBWarning
|
||||||
from openbb_core.app.model.command_context import CommandContext
|
from openbb_core.app.model.command_context import CommandContext
|
||||||
|
from openbb_core.app.model.extension import Extension
|
||||||
from openbb_core.app.model.obbject import OBBject
|
from openbb_core.app.model.obbject import OBBject
|
||||||
from openbb_core.app.model.system_settings import SystemSettings
|
from openbb_core.app.model.system_settings import SystemSettings
|
||||||
from openbb_core.app.model.user_settings import UserSettings
|
from openbb_core.app.model.user_settings import UserSettings
|
||||||
@@ -49,7 +51,7 @@ class MockExecutionContext:
|
|||||||
@property
|
@property
|
||||||
def api_route(self) -> str:
|
def api_route(self) -> str:
|
||||||
"""Mock API route."""
|
"""Mock API route."""
|
||||||
return MockAPIRoute(self.route)
|
return MockAPIRoute(self.route) # type: ignore
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
@@ -303,9 +305,9 @@ def test_command_runner_run(_):
|
|||||||
"""Test run."""
|
"""Test run."""
|
||||||
runner = CommandRunner()
|
runner = CommandRunner()
|
||||||
|
|
||||||
with patch(
|
with patch( # type: ignore
|
||||||
"openbb_core.app.command_runner.StaticCommandRunner",
|
"openbb_core.app.command_runner.StaticCommandRunner",
|
||||||
**{"return_value.run": True},
|
**{"return_value.run": True}, # type: ignore
|
||||||
):
|
):
|
||||||
assert runner.run("mock/route")
|
assert runner.run("mock/route")
|
||||||
|
|
||||||
@@ -371,7 +373,7 @@ async def test_static_command_runner_execute_func(
|
|||||||
mock_command.return_value = OBBject(
|
mock_command.return_value = OBBject(
|
||||||
results=[1, 2, 3, 4],
|
results=[1, 2, 3, 4],
|
||||||
provider="mock_provider",
|
provider="mock_provider",
|
||||||
accessors={"charting": Mock()},
|
accessors={"charting": Mock()}, # type: ignore
|
||||||
)
|
)
|
||||||
mock_chart.return_value = None
|
mock_chart.return_value = None
|
||||||
|
|
||||||
@@ -396,13 +398,13 @@ def test_static_command_runner_chart():
|
|||||||
{"date": "1992", "value": 300},
|
{"date": "1992", "value": 300},
|
||||||
],
|
],
|
||||||
provider="mock_provider",
|
provider="mock_provider",
|
||||||
accessors={"charting": Mock()},
|
accessors={"charting": Mock()}, # type: ignore
|
||||||
)
|
)
|
||||||
mock_obbject.charting.show = Mock()
|
mock_obbject.charting.show = Mock() # type: ignore
|
||||||
|
|
||||||
StaticCommandRunner._chart(mock_obbject) # pylint: disable=protected-access
|
StaticCommandRunner._chart(mock_obbject) # pylint: disable=protected-access
|
||||||
|
|
||||||
mock_obbject.charting.show.assert_called_once()
|
mock_obbject.charting.show.assert_called_once() # type: ignore
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -415,7 +417,7 @@ async def test_static_command_runner_command():
|
|||||||
def __init__(self, results, **kwargs):
|
def __init__(self, results, **kwargs):
|
||||||
self.results = results
|
self.results = results
|
||||||
self.extra = {}
|
self.extra = {}
|
||||||
self.provider = kwargs.get("provider_choices").provider
|
self.provider = kwargs.get("provider_choices").provider # type: ignore
|
||||||
|
|
||||||
class MockProviderChoices:
|
class MockProviderChoices:
|
||||||
"""Mock ProviderChoices"""
|
"""Mock ProviderChoices"""
|
||||||
@@ -435,3 +437,116 @@ async def test_static_command_runner_command():
|
|||||||
|
|
||||||
assert result.results == [1, 2, 3, 4]
|
assert result.results == [1, 2, 3, 4]
|
||||||
assert result.provider == "mock_provider"
|
assert result.provider == "mock_provider"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extension_immutable_preserves_original_and_does_not_set_extension_modified(
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""Immutable extensions must run against a copy and must not mutate the original OBBject."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"openbb_core.app.service.system_service.SystemService",
|
||||||
|
lambda: SimpleNamespace(
|
||||||
|
system_settings=SimpleNamespace(
|
||||||
|
allow_on_command_output=True, allow_mutable_extensions=False
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
ext = Extension(name="imm_ext_test", on_command_output=True, immutable=True)
|
||||||
|
|
||||||
|
def imm_accessor(self):
|
||||||
|
# Mutate the (copied) obbject if called
|
||||||
|
if isinstance(getattr(self, "results", None), list):
|
||||||
|
self.results.append("modified_by_imm")
|
||||||
|
|
||||||
|
# Attach accessor to OBBject class for binding on instances (monkeypatch will revert)
|
||||||
|
monkeypatch.setattr(OBBject, ext.name, imm_accessor, raising=False)
|
||||||
|
|
||||||
|
fake_loader = SimpleNamespace(on_command_output_callbacks={"*": [ext]})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"openbb_core.app.command_runner.ExtensionLoader", lambda: fake_loader
|
||||||
|
)
|
||||||
|
|
||||||
|
obb = OBBject(results=[1], provider="mock_provider")
|
||||||
|
|
||||||
|
StaticCommandRunner._trigger_command_output_callbacks("any/route", obb)
|
||||||
|
|
||||||
|
# original must remain unchanged
|
||||||
|
assert obb.results == [1]
|
||||||
|
# immutable extension should not mark the original as modified
|
||||||
|
assert getattr(obb, "_extension_modified", False) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_extension_mutable_modifies_original_and_sets_extension_modified_and_route_scoping(
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""Mutable extensions must modify the original OBBject and set the modification flag;
|
||||||
|
registration must be route-scoped."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"openbb_core.app.service.system_service.SystemService",
|
||||||
|
lambda: SimpleNamespace(
|
||||||
|
system_settings=SimpleNamespace(
|
||||||
|
allow_mutable_extensions=True, allow_on_command_output=True
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
ext = Extension(name="mut_ext_test", on_command_output=True, immutable=False)
|
||||||
|
|
||||||
|
def mut_accessor(self):
|
||||||
|
if isinstance(getattr(self, "results", None), list):
|
||||||
|
self.results.append("modified_by_mut")
|
||||||
|
|
||||||
|
monkeypatch.setattr(OBBject, ext.name, mut_accessor, raising=False)
|
||||||
|
|
||||||
|
# register the extension only for "mock/route"
|
||||||
|
fake_loader = SimpleNamespace(on_command_output_callbacks={"mock/route": [ext]})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"openbb_core.app.command_runner.ExtensionLoader", lambda: fake_loader
|
||||||
|
)
|
||||||
|
|
||||||
|
obb = OBBject(results=[], provider="mock_provider")
|
||||||
|
|
||||||
|
# not executed for other routes
|
||||||
|
StaticCommandRunner._trigger_command_output_callbacks("other/route", obb)
|
||||||
|
assert obb.results == []
|
||||||
|
|
||||||
|
# executed for the registered route and should mutate original
|
||||||
|
StaticCommandRunner._trigger_command_output_callbacks("mock/route", obb)
|
||||||
|
assert obb.results == ["modified_by_mut"]
|
||||||
|
assert getattr(obb, "_extension_modified", False) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_results_only_flag_sets_attribute_and_accessor_runs(monkeypatch):
|
||||||
|
"""Extensions that declare results_only should toggle the _results_only attribute
|
||||||
|
and still run their accessor."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"openbb_core.app.service.system_service.SystemService",
|
||||||
|
lambda: SimpleNamespace(
|
||||||
|
system_settings=SimpleNamespace(
|
||||||
|
allow_on_command_output=True, allow_mutable_extensions=False
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
ext = Extension(
|
||||||
|
name="ro_ext_test", on_command_output=True, results_only=True, immutable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
called = {"hit": False}
|
||||||
|
|
||||||
|
def ro_accessor(self):
|
||||||
|
called["hit"] = True
|
||||||
|
|
||||||
|
monkeypatch.setattr(OBBject, ext.name, ro_accessor, raising=False)
|
||||||
|
|
||||||
|
fake_loader = SimpleNamespace(on_command_output_callbacks={"*": [ext]})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"openbb_core.app.command_runner.ExtensionLoader", lambda: fake_loader
|
||||||
|
)
|
||||||
|
|
||||||
|
obb = OBBject(results=[1, 2, 3], provider="mock_provider")
|
||||||
|
|
||||||
|
StaticCommandRunner._trigger_command_output_callbacks("any/route", obb)
|
||||||
|
|
||||||
|
# results_only attribute must be set on the output OBBject
|
||||||
|
assert getattr(obb, "_results_only", False) is True
|
||||||
|
# accessor should have been called (even if on a copy for immutable extensions)
|
||||||
|
assert called["hit"] is True
|
||||||
|
|||||||
@@ -472,24 +472,63 @@ class Charting:
|
|||||||
|
|
||||||
def show(self, render: bool = True, **kwargs):
|
def show(self, render: bool = True, **kwargs):
|
||||||
"""Display chart and save it to the OBBject."""
|
"""Display chart and save it to the OBBject."""
|
||||||
|
# pylint: disable=import-outside-toplevel
|
||||||
|
from openbb_charting.core.openbb_figure import OpenBBFigure
|
||||||
|
|
||||||
try:
|
try:
|
||||||
charting_function = self._get_chart_function(
|
charting_function = self._get_chart_function(
|
||||||
self._obbject._route # pylint: disable=protected-access
|
self._obbject._route # pylint: disable=protected-access # type: ignore
|
||||||
)
|
)
|
||||||
kwargs["obbject_item"] = self._obbject.results
|
kwargs["obbject_item"] = self._obbject.results
|
||||||
kwargs["charting_settings"] = self._charting_settings
|
kwargs["charting_settings"] = self._charting_settings
|
||||||
kwargs["standard_params"] = (
|
kwargs["standard_params"] = (
|
||||||
self._obbject._standard_params # pylint: disable=protected-access
|
self._obbject._standard_params # pylint: disable=protected-access
|
||||||
)
|
)
|
||||||
kwargs["extra_params"] = (
|
# If the provider interface isn't used, endpoint kwargs are already here.
|
||||||
self._obbject._extra_params # pylint: disable=protected-access
|
# Don't overwrite them.
|
||||||
|
obb_kwargs = (
|
||||||
|
self._obbject._extra_params or {} # pylint: disable=protected-access
|
||||||
)
|
)
|
||||||
|
if obb_kwargs:
|
||||||
|
for k, v in obb_kwargs.items():
|
||||||
|
kwargs["extra_params"].update({k: v})
|
||||||
|
|
||||||
kwargs["provider"] = self._obbject.provider
|
kwargs["provider"] = self._obbject.provider
|
||||||
kwargs["extra"] = self._obbject.extra
|
kwargs["extra"] = self._obbject.extra
|
||||||
fig, content = charting_function(**kwargs)
|
|
||||||
content = fig.show(external=True, **kwargs).to_plotly_json() # type: ignore
|
# Handle different types of output from the charting endpoint.
|
||||||
self._obbject.chart = Chart(fig=fig, content=content, format=self._format)
|
chart_response: Any = charting_function(**kwargs)
|
||||||
if render:
|
|
||||||
|
# If returned a Chart object, set as-is.
|
||||||
|
if isinstance(chart_response, Chart):
|
||||||
|
self._obbject.chart = chart_response
|
||||||
|
# If just an OpenBBFigure gets returned, create the serialized version for the API.
|
||||||
|
elif isinstance(chart_response, OpenBBFigure):
|
||||||
|
fig = chart_response
|
||||||
|
content = fig.show(external=True, **kwargs).to_plotly_json()
|
||||||
|
self._obbject.chart = Chart(
|
||||||
|
fig=fig, content=content, format=self._format
|
||||||
|
)
|
||||||
|
# Current functions return this.
|
||||||
|
elif isinstance(chart_response, tuple) and len(chart_response) == 2:
|
||||||
|
fig, content = chart_response
|
||||||
|
|
||||||
|
if isinstance(fig, OpenBBFigure):
|
||||||
|
content = fig.show(external=True, **kwargs).to_plotly_json() # type: ignore
|
||||||
|
self._obbject.chart = Chart(
|
||||||
|
fig=fig, content=content, format=self._format
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._obbject.chart = Chart(
|
||||||
|
fig=fig, content=content, format=type(fig).__name__
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
self._obbject.chart = Chart(
|
||||||
|
fig=chart_response, content=None, format="unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
if render and hasattr(fig, "show"):
|
||||||
fig.show(**kwargs)
|
fig.show(**kwargs)
|
||||||
|
|
||||||
except (RuntimeError, OpenBBError) as e:
|
except (RuntimeError, OpenBBError) as e:
|
||||||
@@ -712,8 +751,9 @@ class Charting:
|
|||||||
self._backend.send_table(
|
self._backend.send_table(
|
||||||
df_table=data_as_df,
|
df_table=data_as_df,
|
||||||
title=title
|
title=title
|
||||||
or self._obbject._route, # pylint: disable=protected-access
|
or ""
|
||||||
theme=self._charting_settings.table_style,
|
or self._obbject._route, # pylint: disable=protected-access # type: ignore
|
||||||
|
theme=self._charting_settings.table_style, # pylint: disable=protected-access
|
||||||
)
|
)
|
||||||
except Exception as e: # pylint: disable=W0718
|
except Exception as e: # pylint: disable=W0718
|
||||||
warn(f"Failed to show figure with backend. {e}")
|
warn(f"Failed to show figure with backend. {e}")
|
||||||
|
|||||||
Reference in New Issue
Block a user