[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:
Danglewood
2025-11-06 09:54:16 -08:00
committed by GitHub
parent 3257b9f5ce
commit ae86e04e57
13 changed files with 527 additions and 37 deletions

View File

@@ -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)

View File

@@ -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."""

View File

@@ -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:

View File

@@ -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)

View File

@@ -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

View File

@@ -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]]:

View File

@@ -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)

View File

@@ -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

View File

@@ -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:

View File

@@ -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:

View 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)

View File

@@ -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

View File

@@ -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}")