diff --git a/openbb_platform/platform/core/README.md b/openbb_platform/platform/core/README.md index e81d046ed330..19a2e4557eaa 100644 --- a/openbb_platform/platform/core/README.md +++ b/openbb_platform/platform/core/README.md @@ -14,11 +14,11 @@ - [4.1 Static version](#41-static-version) - [4.1.1. OBBject](#411-obbject) - [Helpers](#helpers) + - [Extensions](#extensions) - [4.1.2. Utilities](#412-utilities) - [User settings](#user-settings) + - [Preferences](#preferences) - [System settings](#system-settings) - - [Preferences](#preferences) - - [Available preferences and its descriptions](#available-preferences-and-its-descriptions) - [Coverage](#coverage) - [4.1.3. OpenBB Hub Account](#413-openbb-hub-account) - [4.1.4. Command execution](#414-command-execution) @@ -63,7 +63,7 @@ poetry install Build a Python package: ```bash -poetry new openbb-sdk-my_extension +poetry new openbb-platform-my_extension ``` ### Command @@ -235,11 +235,52 @@ date } ``` +#### Extensions + +Steps to create an `OBBject` extension: + +1. Set the following as entry point in your extension .toml file and install it: + + ```toml + ... + [tool.poetry.plugins."openbb_obbject_extension"] + example = "openbb_example:ext" + ``` + +2. Extension code: + + ```python + from openbb_core.app.model.extension import Extension + ext = Extension(name="example", required_credentials=["some_api_key"]) + ``` + +3. Optionally declare an `OBBject` accessor, it will use the extension name: + + ```python + @ext.obbject_accessor + class Example: + def __init__(self, obbject): + self._obbject = obbject + + def hello(self): + api_key = self._obbject._credentials.some_api_key + print(f"Hello, this is my credential: {api_key}!") + ``` + + Usage: + + ```shell + >>> from openbb import obb + >>> obbject = obb.stock.load("AAPL") + >>> obbject.example.hello() + Hello, this is my credential: None! + ``` + ### 4.1.2. Utilities #### User settings -These are your user settings, you can change them anytime and they will be applied. Don't forget to `sdk.account.save()` if you want these changes to persist. +These are your user settings, you can change them anytime and they will be applied. Don't forget to `obb.account.save()` if you want these changes to persist. ```python from openbb import obb @@ -250,16 +291,6 @@ obb.user.preferences obb.user.defaults ``` -#### System settings - -Check your system settings. - -```python -from openbb import obb - -obb.system -``` - #### Preferences Check your preferences by adjusting the `user_settings.json` file inside your **home** directory. @@ -282,7 +313,7 @@ Here is an example of how your `user_settings.json` file can look like: > Note that the user preferences shouldn't be confused with environment variables. -##### Available preferences and its descriptions +These are the available preferences and respective descriptions: |Preference |Default |Description | |---------------------|--------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| @@ -300,6 +331,15 @@ Here is an example of how your `user_settings.json` file can look like: |metadata |True |Enables or disables the collection of metadata which provides information about operations including arguments duration route and timestamp. Disabling this feature may improve performance in cases where contextual information is not needed or when the additional computation time and storage space are a concern.| |output_type |OBBject |Specifies the type of data the application will output when a command or endpoint is accessed. Note that choosing data formats only available in Python such as `dataframe`, `numpy` or `polars` will render the application's API non-functional. | +#### System settings + +Check your system settings. + +```python +from openbb import obb + +obb.system +``` #### Coverage @@ -389,7 +429,7 @@ To apply an environment variable use one of the following: ```python import os os.environ["OPENBB_DEBUG_MODE"] = "True" - from openbb import sdk + from openbb import obb ``` 2. Persistent: create a `.env` file in `/.openbb_platform` folder inside your home directory with diff --git a/openbb_platform/platform/core/openbb_core/app/model/credentials.py b/openbb_platform/platform/core/openbb_core/app/model/credentials.py index d8d0132fa394..79181ef716ab 100644 --- a/openbb_platform/platform/core/openbb_core/app/model/credentials.py +++ b/openbb_platform/platform/core/openbb_core/app/model/credentials.py @@ -1,40 +1,103 @@ -from typing import Dict, List, Optional, Tuple - -from pydantic import ConfigDict, SecretStr, create_model, field_serializer +import traceback +from typing import Dict, Optional, Set, Tuple + +from importlib_metadata import entry_points +from pydantic import ( + BaseModel, + ConfigDict, + Field, + SecretStr, + create_model, +) +from pydantic.functional_serializers import PlainSerializer +from typing_extensions import Annotated +from openbb_core.app.model.extension import Extension from openbb_core.app.provider_interface import ProviderInterface -# Here we create the BaseModel from the provider required credentials. -# This means that if a new provider extension is installed, the required -# credentials will be automatically added to the Credentials model. - - -def format_map( - required_credentials: List[str], -) -> Dict[str, Tuple[object, None]]: - """Format credentials map to be used in the Credentials model""" - formatted: Dict[str, Tuple[object, None]] = {} - for c in required_credentials: - formatted[c] = (Optional[SecretStr], None) - - return formatted +class LoadingError(Exception): + """Error loading extension.""" + + +# @model_serializer blocks model_dump with pydantic parameters (include, exclude) +OBBSecretStr = Annotated[ + SecretStr, + PlainSerializer( + lambda x: x.get_secret_value(), return_type=str, when_used="json-unless-none" + ), +] + + +class CredentialsLoader: + """Here we create the Credentials model""" + + credentials: Dict[str, Set[str]] = {} + + @staticmethod + def prepare( + required_credentials: Dict[str, Set[str]], + ) -> Dict[str, Tuple[object, None]]: + """Prepare credentials map to be used in the Credentials model""" + formatted: Dict[str, Tuple[object, None]] = {} + for origin, creds in required_credentials.items(): + for c in creds: + # Not sure we should do this, if you require the same credential it breaks + # if c in formatted: + # raise ValueError(f"Credential '{c}' already in use.") + formatted[c] = ( + Optional[OBBSecretStr], + Field( + default=None, description=origin + ), # register the credential origin (obbject, providers) + ) + + return formatted + + def from_obbject(self) -> None: + """Load credentials from OBBject extensions""" + self.credentials["obbject"] = set() + for entry_point in sorted(entry_points(group="openbb_obbject_extension")): + try: + entry = entry_point.load() + if isinstance(entry, Extension): + for c in entry.required_credentials: + self.credentials["obbject"].add(c) + except Exception as e: + traceback.print_exception(type(e), e, e.__traceback__) + raise LoadingError(f"Invalid extension '{entry_point.name}'") from e + + def from_providers(self) -> None: + """Load credentials from providers""" + self.credentials["providers"] = set() + for c in ProviderInterface().required_credentials: + self.credentials["providers"].add(c) + + def load(self) -> BaseModel: + """Load credentials from providers""" + # We load providers first to give them priority choosing credential names + self.from_providers() + self.from_obbject() + return create_model( # type: ignore + "Credentials", + __config__=ConfigDict(validate_assignment=True), + **self.prepare(self.credentials), + ) -provider_credentials = ProviderInterface().required_credentials -_Credentials = create_model( # type: ignore - "Credentials", - __config__=ConfigDict(validate_assignment=True), - **format_map(provider_credentials), -) +_Credentials = CredentialsLoader().load() -class Credentials(_Credentials): +class Credentials(_Credentials): # type: ignore """Credentials model used to store provider credentials""" - @field_serializer(*provider_credentials, when_used="json-unless-none") - def _dump_secret(self, v): - return v.get_secret_value() + def __repr__(self) -> str: + """String representation of the credentials""" + return ( + self.__class__.__name__ + + "\n\n" + + "\n".join([f"{k}: {v}" for k, v in self.__dict__.items()]) + ) def show(self): """Unmask credentials and print them""" @@ -43,14 +106,3 @@ def show(self): + "\n\n" + "\n".join([f"{k}: {v}" for k, v in self.model_dump(mode="json").items()]) ) - - -def __repr__(self: Credentials) -> str: - return ( - self.__class__.__name__ - + "\n\n" - + "\n".join([f"{k}: {v}" for k, v in self.model_dump().items()]) - ) - - -Credentials.__repr__ = __repr__ diff --git a/openbb_platform/platform/core/openbb_core/app/model/extension.py b/openbb_platform/platform/core/openbb_core/app/model/extension.py new file mode 100644 index 000000000000..587e337c8d00 --- /dev/null +++ b/openbb_platform/platform/core/openbb_core/app/model/extension.py @@ -0,0 +1,70 @@ +import warnings +from typing import Callable, List, Optional + + +class Extension: + """Serves as extension entry point and must be created by each extension package. + + See README.md for more information on how to create an extension. + """ + + def __init__( + self, + name: str, + required_credentials: Optional[List[str]] = None, + ) -> None: + """Initialize the extension. + + Parameters + ---------- + name : str + Name of the extension. + required_credentials : Optional[List[str]], optional + List of required credentials, by default None + """ + self.name = name + self.required_credentials = required_credentials or [] + + @property + def obbject_accessor(self) -> Callable: + """Extend an OBBject, inspired by pandas.""" + # pylint: disable=import-outside-toplevel + # Avoid circular imports + + from openbb_core.app.model.obbject import OBBject + + return self.register_accessor(self.name, OBBject) + + @staticmethod + def register_accessor(name, cls) -> Callable: + """Register a custom accessor""" + + def decorator(accessor): + if hasattr(cls, name): + warnings.warn( + f"registration of accessor '{repr(accessor)}' under name " + f"'{repr(name)}' for type '{repr(cls)}' is overriding a preexisting " + f"attribute with the same name.", + UserWarning, + ) + setattr(cls, name, CachedAccessor(name, accessor)) + # pylint: disable=protected-access + cls._accessors.add(name) + return accessor + + return decorator + + +class CachedAccessor: + """CachedAccessor""" + + def __init__(self, name: str, accessor) -> None: + self._name = name + self._accessor = accessor + + def __get__(self, obj, cls): + if obj is None: + return self._accessor + accessor_obj = self._accessor(obj) + object.__setattr__(obj, self._name, accessor_obj) + return accessor_obj diff --git a/openbb_platform/platform/core/openbb_core/app/model/obbject.py b/openbb_platform/platform/core/openbb_core/app/model/obbject.py index 1296d4faca11..7d1b25bea234 100644 --- a/openbb_platform/platform/core/openbb_core/app/model/obbject.py +++ b/openbb_platform/platform/core/openbb_core/app/model/obbject.py @@ -1,12 +1,22 @@ """The OBBject.""" from re import sub -from typing import TYPE_CHECKING, Any, Dict, Generic, List, Literal, Optional, TypeVar +from typing import ( + TYPE_CHECKING, + Any, + ClassVar, + Dict, + Generic, + List, + Literal, + Optional, + Set, + TypeVar, +) import pandas as pd from numpy import ndarray from pydantic import BaseModel, Field -from openbb_core.app.charting_service import ChartingService from openbb_core.app.model.abstract.error import OpenBBError from openbb_core.app.model.abstract.tagged import Tagged from openbb_core.app.model.abstract.warning import Warning_ @@ -47,6 +57,8 @@ class OBBject(Tagged, Generic[T]): default_factory=dict, description="Extra info.", ) + _credentials: ClassVar[Optional[BaseModel]] = None + _accessors: ClassVar[Set[str]] = set() def __repr__(self) -> str: """Human readable representation of the object.""" @@ -236,6 +248,9 @@ def to_chart(self, **kwargs): chart.fig The chart figure. """ + # pylint: disable=import-outside-toplevel + # Avoids circular import + from openbb_core.app.charting_service import ChartingService cs = ChartingService() kwargs["data"] = self.to_dataframe() diff --git a/openbb_platform/platform/core/openbb_core/app/model/user_settings.py b/openbb_platform/platform/core/openbb_core/app/model/user_settings.py index 7ba9fe68706d..11795515bb2d 100644 --- a/openbb_platform/platform/core/openbb_core/app/model/user_settings.py +++ b/openbb_platform/platform/core/openbb_core/app/model/user_settings.py @@ -14,6 +14,8 @@ class UserSettings(Tagged): defaults: Defaults = Field(default_factory=Defaults) def __repr__(self) -> str: + # We use the __dict__ because Credentials.model_dump() will use the serializer + # and unmask the credentials return f"{self.__class__.__name__}\n\n" + "\n".join( - f"{k}: {v}" for k, v in self.model_dump().items() + f"{k}: {v}" for k, v in self.__dict__.items() ) diff --git a/openbb_platform/platform/core/openbb_core/app/static/container.py b/openbb_platform/platform/core/openbb_core/app/static/container.py index 6f5c43205643..c69bbba13054 100644 --- a/openbb_platform/platform/core/openbb_core/app/static/container.py +++ b/openbb_platform/platform/core/openbb_core/app/static/container.py @@ -12,6 +12,7 @@ class Container: def __init__(self, command_runner: CommandRunner) -> None: self._command_runner = command_runner + OBBject._credentials = command_runner.user_settings.credentials def _run(self, *args, **kwargs) -> Union[OBBject, pd.DataFrame, dict]: """Run a command in the container.""" diff --git a/openbb_platform/platform/core/tests/app/model/test_obbject.py b/openbb_platform/platform/core/tests/app/model/test_obbject.py index 0f8868226322..37148b3c0af1 100644 --- a/openbb_platform/platform/core/tests/app/model/test_obbject.py +++ b/openbb_platform/platform/core/tests/app/model/test_obbject.py @@ -284,7 +284,7 @@ def test_to_dict(results, expected_dict): @patch("openbb_core.app.model.obbject.OBBject.to_dataframe") -@patch("openbb_core.app.model.obbject.ChartingService") +@patch("openbb_core.app.charting_service.ChartingService") def test_to_chart_with_new_chart( mock_charting_service, mock_to_dataframe, diff --git a/openbb_platform/platform/core/tests/app/static/test_container.py b/openbb_platform/platform/core/tests/app/static/test_container.py index dc1e01b28010..dd2d7df0eff5 100644 --- a/openbb_platform/platform/core/tests/app/static/test_container.py +++ b/openbb_platform/platform/core/tests/app/static/test_container.py @@ -1,9 +1,10 @@ """Test the container.py file.""" +from openbb_core.app.command_runner import CommandRunner from openbb_core.app.static.container import Container def test_container_init(): """Test container init.""" - container = Container(None) + container = Container(CommandRunner()) assert container