openbb-platform-api 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,37 @@
1
+ Metadata-Version: 2.1
2
+ Name: openbb-platform-api
3
+ Version: 1.0.0
4
+ Summary: OpenBB Platform API: Launch script and widgets builder for the OpenBB Platform API and Terminal Pro Connector.
5
+ Home-page: https://openbb.co
6
+ License: AGPL-3.0-only
7
+ Author: OpenBB
8
+ Author-email: hello@openbb.co
9
+ Requires-Python: >=3.9,<3.13
10
+ Classifier: License :: OSI Approved :: GNU Affero General Public License v3
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Requires-Dist: black
17
+ Requires-Dist: deepdiff
18
+ Requires-Dist: openbb-core
19
+ Requires-Dist: poetry (>=1.8,<2.0)
20
+ Requires-Dist: ruff
21
+ Requires-Dist: setuptools
22
+ Project-URL: Documentation, https://docs.openbb.co
23
+ Project-URL: Repository, https://github.com/openbb-finance/openbb
24
+ Description-Content-Type: text/markdown
25
+
26
+ # OpenBB Platform API Meta Package
27
+
28
+ This is a meta package entry point for the OpenBB Platform API, and the OpenBB Terminal Pro data connector widgets build script.
29
+
30
+ ## Installation
31
+
32
+ Install this package locally by navigating into the directory and entering:
33
+
34
+ ```sh
35
+ pip install -e .
36
+ ```
37
+
@@ -0,0 +1,11 @@
1
+ # OpenBB Platform API Meta Package
2
+
3
+ This is a meta package entry point for the OpenBB Platform API, and the OpenBB Terminal Pro data connector widgets build script.
4
+
5
+ ## Installation
6
+
7
+ Install this package locally by navigating into the directory and entering:
8
+
9
+ ```sh
10
+ pip install -e .
11
+ ```
@@ -0,0 +1 @@
1
+ """OpenBB Platform API Meta Package."""
@@ -0,0 +1,133 @@
1
+ """OpenBB Platform API.
2
+
3
+ Launch script and widgets builder for the OpenBB Terminal Custom Backend.
4
+ """
5
+
6
+ import json
7
+ import os
8
+
9
+ import uvicorn
10
+ from fastapi.responses import JSONResponse
11
+ from openbb_core.api.rest_api import app
12
+
13
+ from .utils.api import check_port, get_user_settings, get_widgets_json, parse_args
14
+
15
+ FIRST_RUN = True
16
+
17
+ HOME = os.environ.get("HOME") or os.environ.get("USERPROFILE")
18
+
19
+ if not HOME:
20
+ raise ValueError("HOME or USERPROFILE environment variable not set.")
21
+
22
+ CURRENT_USER_SETTINGS = os.path.join(HOME, ".openbb_platform", "user_settings.json")
23
+ USER_SETTINGS_COPY = os.path.join(HOME, ".openbb_platform", "user_settings_backup.json")
24
+
25
+ # Widget filtering is optional and can be used to exclude widgets from the widgets.json file
26
+ # You can generate this filter on OpenBB Hub: https://my.openbb.co/app/platform/widgets
27
+ WIDGET_SETTINGS = os.path.join(HOME, ".openbb_platform", "widget_settings.json")
28
+
29
+ kwargs = parse_args()
30
+ build = kwargs.pop("build", True)
31
+ build = False if kwargs.pop("no-build", None) else build
32
+ login = kwargs.pop("login", False)
33
+ dont_filter = kwargs.pop("no-filter", False)
34
+
35
+ if not dont_filter and os.path.exists(WIDGET_SETTINGS):
36
+ with open(WIDGET_SETTINGS) as f:
37
+ try:
38
+ widget_exclude_filter = json.load(f)["exclude"]
39
+ except json.JSONDecodeError:
40
+ widget_exclude_filter = []
41
+ else:
42
+ widget_exclude_filter = []
43
+
44
+ openapi = app.openapi()
45
+
46
+ # We don't need the current settings,
47
+ # but we need to call the function to update, login, and/or identify the settings file.
48
+ current_settings = get_user_settings(login, CURRENT_USER_SETTINGS, USER_SETTINGS_COPY)
49
+
50
+ widgets_json = get_widgets_json(build, openapi, widget_exclude_filter)
51
+
52
+
53
+ @app.get("/")
54
+ async def get_root():
55
+ """Root response and welcome message."""
56
+ return JSONResponse(
57
+ content="Welcome to the OpenBB Platform API."
58
+ + " Learn how to connect to Pro in docs.openbb.co/pro/data-connectors,"
59
+ + " or see the API documentation here: /docs"
60
+ )
61
+
62
+
63
+ @app.get("/widgets.json")
64
+ async def get_widgets():
65
+ """Widgets configuration file for the OpenBB Terminal Pro."""
66
+ # This allows us to serve an edited widgets.json file without reloading the server.
67
+ global FIRST_RUN # noqa PLW0603 # pylint: disable=global-statement
68
+ if FIRST_RUN is True:
69
+ FIRST_RUN = False
70
+ return JSONResponse(content=widgets_json)
71
+ return JSONResponse(content=get_widgets_json(False, openapi, widget_exclude_filter))
72
+
73
+
74
+ def launch_api(**_kwargs): # noqa PRL0912
75
+ """Main function."""
76
+ host = _kwargs.pop("host", os.getenv("OPENBB_API_HOST", "127.0.0.1"))
77
+ if not host:
78
+ print( # noqa: T201
79
+ "\n\nOPENBB_API_HOST is set incorrectly. It should be an IP address or hostname."
80
+ )
81
+ host = input("Enter the host IP address or hostname: ")
82
+ if not host:
83
+ host = "127.0.0.1"
84
+
85
+ port = _kwargs.pop("port", os.getenv("OPENBB_API_PORT", "6900"))
86
+
87
+ try:
88
+ port = int(port)
89
+ except ValueError:
90
+ print( # noqa: T201
91
+ "\n\nOPENBB_API_PORT is set incorrectly. It should be an port number."
92
+ )
93
+ port = input("Enter the port number: ")
94
+ try:
95
+ port = int(port)
96
+ except ValueError:
97
+ print("\n\nInvalid port number. Defaulting to 6900.") # noqa: T201
98
+ port = 6900
99
+ if port < 1025:
100
+ port = 6900
101
+ print( # noqa: T201
102
+ "\n\nInvalid port number, must be above 1024. Defaulting to 6900."
103
+ )
104
+
105
+ free_port = check_port(host, port)
106
+
107
+ if free_port != port:
108
+ print( # noqa: T201
109
+ f"\n\nPort {port} is already in use. Using port {free_port}.\n"
110
+ )
111
+ port = free_port
112
+
113
+ try:
114
+ package_name = __package__
115
+ uvicorn.run(f"{package_name}.main:app", host=host, port=port, **_kwargs)
116
+ finally:
117
+ # If user_settings_copy.json exists, then restore the original settings.
118
+ if os.path.exists(USER_SETTINGS_COPY):
119
+ print("\n\nRestoring the original settings.\n") # noqa: T201
120
+ os.replace(USER_SETTINGS_COPY, CURRENT_USER_SETTINGS)
121
+
122
+
123
+ def main():
124
+ """Launch the API."""
125
+ launch_api(**kwargs)
126
+
127
+
128
+ if __name__ == "__main__":
129
+
130
+ try:
131
+ main()
132
+ except KeyboardInterrupt:
133
+ print("Restoring the original settings.") # noqa: T201
@@ -0,0 +1 @@
1
+ """OpenBB Platform API Utils."""
@@ -0,0 +1,244 @@
1
+ """API Utils."""
2
+
3
+ import json
4
+ import os
5
+ import socket
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Dict
9
+
10
+ from deepdiff import DeepDiff
11
+
12
+ from .widgets import build_json
13
+
14
+ LAUNCH_SCRIPT_DESCRIPTION = """
15
+ Serve the OpenBB Platform API.
16
+
17
+
18
+ Launcher specific arguments:
19
+
20
+ --build Build the widgets.json file.
21
+ --no-build Do not build the widgets.json file.
22
+ --login Login to the OpenBB Platform.
23
+ --no-filter Do not filter the widgets.json file.
24
+
25
+
26
+ All other arguments will be passed to uvicorn. Here are the most common ones:
27
+
28
+ --host TEXT Host IP address or hostname.
29
+ [default: 127.0.0.1]
30
+ --port INTEGER Port number.
31
+ [default: 6900]
32
+ --ssl-keyfile TEXT SSL key file.
33
+ --ssl-certfile TEXT SSL certificate file.
34
+ --ssl-keyfile-password TEXT SSL keyfile password.
35
+ --ssl-version INTEGER SSL version to use.
36
+ (see stdlib ssl module's)
37
+ [default: 17]
38
+ --ssl-cert-reqs INTEGER Whether client certificate is required.
39
+ (see stdlib ssl module's)
40
+ [default: 0]
41
+ --ssl-ca-certs TEXT CA certificates file.
42
+ --ssl-ciphers TEXT Ciphers to use.
43
+ (see stdlib ssl module's)
44
+ [default: TLSv1]
45
+
46
+ Run `uvicorn --help` to get the full list of arguments.
47
+ """
48
+
49
+
50
+ def check_port(host, port):
51
+ """Check if the port number is free."""
52
+ port = int(port)
53
+ not_free = True
54
+ while not_free:
55
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
56
+ res = sock.connect_ex((host, port))
57
+ if res != 0:
58
+ not_free = False
59
+ else:
60
+ port += 1
61
+ return port
62
+
63
+
64
+ def get_user_settings(
65
+ _login: bool, current_user_settings: str, user_settings_copy: str
66
+ ):
67
+ """Login to the OpenBB Platform."""
68
+ # pylint: disable=import-outside-toplevel
69
+ import getpass
70
+
71
+ if Path(current_user_settings).exists():
72
+ with open(current_user_settings, encoding="utf-8") as f:
73
+ _current_settings = json.load(f)
74
+ else:
75
+ _current_settings = {
76
+ "credentials": {},
77
+ "preferences": {},
78
+ "defaults": {"commands": {}},
79
+ }
80
+ if (isinstance(_login, str) and _login.lower() == "false") or not _login:
81
+ return _current_settings
82
+
83
+ pat = getpass.getpass(
84
+ "\n\nEnter your personal access token (PAT) to authorize the API and update your local settings."
85
+ + "\nSkip to use a pre-configured 'user_settings.json' file."
86
+ + "\nPress Enter to skip or copy (entered values are not displayed on screen) your PAT to the command line: "
87
+ )
88
+
89
+ if pat:
90
+ from openbb_core.app.service.hub_service import HubService
91
+
92
+ hub_credentials: Dict = {}
93
+ hub_preferences: Dict = {}
94
+ hub_defaults: Dict = {}
95
+ try:
96
+ Hub = HubService()
97
+ _ = Hub.connect(pat=pat)
98
+ hub_settings = Hub.pull()
99
+ hub_credentials = json.loads(
100
+ hub_settings.credentials.model_dump_json() # pylint: disable=no-member
101
+ )
102
+ hub_preferences = json.loads(
103
+ hub_settings.preferences.model_dump_json() # pylint: disable=no-member
104
+ )
105
+ hub_defaults = json.loads(
106
+ hub_settings.defaults.model_dump_json() # pylint: disable=no-member
107
+ )
108
+ except Exception as e: # pylint: disable=broad-exception-caught
109
+ print( # noqa: T201
110
+ f"\n\nError connecting with Hub:\n{e}\n\nUsing the local settings.\n"
111
+ )
112
+
113
+ if hub_credentials:
114
+ # Prompt the user to ask if they want to persist the new settings
115
+ persist_input = (
116
+ input(
117
+ "\n\nDo you want to persist the new settings?"
118
+ + " Not recommended for public machines. (yes/no): "
119
+ )
120
+ .strip()
121
+ .lower()
122
+ )
123
+
124
+ if persist_input in ["yes", "y"]:
125
+ PERSIST = True
126
+ elif persist_input in ["no", "n"]:
127
+ PERSIST = False
128
+ else:
129
+ print( # noqa: T201
130
+ "\n\nInvalid input. Defaulting to not persisting the new settings."
131
+ )
132
+ PERSIST = False
133
+
134
+ # Save the current settings to restore at the end of the session.
135
+ if PERSIST is False:
136
+ with open(user_settings_copy, "w", encoding="utf-8") as f:
137
+ json.dump(_current_settings, f, indent=4)
138
+
139
+ new_settings = _current_settings.copy()
140
+ new_settings.setdefault("credentials", {})
141
+ new_settings.setdefault("preferences", {})
142
+ new_settings.setdefault("defaults", {"commands": {}})
143
+
144
+ # Update the current settings with the new settings
145
+ if hub_credentials:
146
+ for k, v in hub_credentials.items():
147
+ if v:
148
+ new_settings["credentials"][k] = v.strip('"').strip("'")
149
+
150
+ if hub_preferences:
151
+ for k, v in hub_preferences.items():
152
+ if v:
153
+ new_settings["preferences"][k] = v
154
+
155
+ if hub_defaults:
156
+ for k, v in hub_defaults.items():
157
+ if k == "commands":
158
+ for key, value in hub_defaults["commands"].items():
159
+ if value:
160
+ new_settings["defaults"]["commands"][key] = value
161
+ elif v:
162
+ new_settings["defaults"][k] = v
163
+ else:
164
+ continue
165
+
166
+ # Write the new settings to the user_settings.json file
167
+ with open(current_user_settings, "w", encoding="utf-8") as f:
168
+ json.dump(new_settings, f, indent=4)
169
+
170
+ _current_settings = new_settings
171
+
172
+ return _current_settings
173
+
174
+
175
+ def get_widgets_json(_build: bool, _openapi, widget_exclude_filter: list):
176
+ """Generate and serve the widgets.json for the OpenBB Platform API."""
177
+ python_path = Path(sys.executable)
178
+ parent_path = python_path.parent if os.name == "nt" else python_path.parents[1]
179
+ widgets_json_path = parent_path.joinpath("assets", "widgets.json").resolve()
180
+ json_exists = widgets_json_path.exists()
181
+
182
+ if not json_exists:
183
+ widgets_json_path.parent.mkdir(parents=True, exist_ok=True)
184
+ _build = True
185
+
186
+ existing_widgets_json: Dict = {}
187
+
188
+ if json_exists:
189
+ with open(widgets_json_path, encoding="utf-8") as f:
190
+ existing_widgets_json = json.load(f)
191
+
192
+ _widgets_json = (
193
+ existing_widgets_json
194
+ if _build is False
195
+ else build_json(_openapi, widget_exclude_filter)
196
+ )
197
+
198
+ if _build:
199
+ diff = DeepDiff(existing_widgets_json, _widgets_json, ignore_order=True)
200
+ merge_prompt = None
201
+ if diff and json_exists:
202
+ print("Differences found:", diff) # noqa: T201
203
+ merge_prompt = input(
204
+ "\nDo you want to overwrite the existing widgets.json configuration?"
205
+ "\nEnter 'n' to append existing with only new entries, or 'i' to ignore all changes. (y/n/i): "
206
+ )
207
+ if merge_prompt.lower().startswith("n"):
208
+ _widgets_json.update(existing_widgets_json)
209
+ elif merge_prompt.lower().startswith("i"):
210
+ _widgets_json = existing_widgets_json
211
+
212
+ if merge_prompt is None or not merge_prompt.lower().startswith("i"):
213
+ try:
214
+ with open(widgets_json_path, "w", encoding="utf-8") as f:
215
+ json.dump(_widgets_json, f, ensure_ascii=False, indent=4)
216
+ except Exception as e: # pylint: disable=broad-exception-caught
217
+ print( # noqa: T201
218
+ f"Error writing widgets.json: {e}. Loading from memory instead."
219
+ )
220
+ _widgets_json = (
221
+ existing_widgets_json
222
+ if existing_widgets_json
223
+ else build_json(_openapi, widget_exclude_filter)
224
+ )
225
+
226
+ return _widgets_json
227
+
228
+
229
+ def parse_args():
230
+ """Parse the launch script command line arguments."""
231
+ args = sys.argv[1:].copy()
232
+ _kwargs: Dict = {}
233
+ for i, arg in enumerate(args):
234
+ if arg == "--help":
235
+ print(LAUNCH_SCRIPT_DESCRIPTION) # noqa: T201
236
+ sys.exit(0)
237
+ if arg.startswith("--"):
238
+ key = arg[2:]
239
+ if i + 1 < len(args) and not args[i + 1].startswith("--"):
240
+ value = args[i + 1]
241
+ _kwargs[key] = value
242
+ else:
243
+ _kwargs[key] = True
244
+ return _kwargs
@@ -0,0 +1,454 @@
1
+ """OpenAPI parsing Utils."""
2
+
3
+ from openbb_core.provider.utils.helpers import to_snake_case
4
+
5
+
6
+ def extract_providers(params: list[dict]) -> list[str]:
7
+ """
8
+ Extract provider options from parameters.
9
+
10
+ Parameters
11
+ ----------
12
+ params : List[Dict]
13
+ List of parameter dictionaries.
14
+
15
+ Returns
16
+ -------
17
+ List[str]
18
+ List of provider options.
19
+ """
20
+ provider_params = [p for p in params if p["name"] == "provider"]
21
+ if provider_params:
22
+ return provider_params[0].get("schema", {}).get("enum", [])
23
+ return []
24
+
25
+
26
+ def set_parameter_type(p: dict, p_schema: dict):
27
+ """
28
+ Determine and set the type for the parameter.
29
+
30
+ Parameters
31
+ ----------
32
+ p : Dict
33
+ Processed parameter dictionary.
34
+ p_schema : Dict
35
+ Schema dictionary for the parameter.
36
+ """
37
+ p_type = p_schema.get("type") if not p.get("type") else p.get("type")
38
+
39
+ if p_type == "string":
40
+ p["type"] = "text"
41
+
42
+ if p_type in ("float", "integer") or (
43
+ not isinstance(p["value"], bool) and isinstance(p["value"], (int, float))
44
+ ):
45
+ p["type"] = "number"
46
+
47
+ if (
48
+ p_type == "boolean"
49
+ or p_schema.get("type") == "boolean"
50
+ or ("anyOf" in p_schema and p_schema["anyOf"][0].get("type") == "boolean")
51
+ ):
52
+ p["type"] = "boolean"
53
+
54
+ if "date" in p["parameter_name"]:
55
+ p["type"] = "date"
56
+
57
+ if "timeframe" in p["parameter_name"]:
58
+ p["type"] = "text"
59
+
60
+ if p["parameter_name"] == "limit":
61
+ p["type"] = "number"
62
+
63
+ if p.get("type") in ("array", "list") or isinstance(p.get("type"), list):
64
+ p["type"] = "text"
65
+
66
+ return p
67
+
68
+
69
+ def set_parameter_options(p: dict, p_schema: dict, providers: list[str]) -> dict:
70
+ """
71
+ Set options for the parameter based on the schema.
72
+
73
+ Parameters
74
+ ----------
75
+ p : Dict
76
+ Processed parameter dictionary.
77
+ p_schema : Dict
78
+ Schema dictionary for the parameter.
79
+ providers : List[str]
80
+ List of provider options.
81
+
82
+ Returns
83
+ -------
84
+ Dict
85
+ Updated parameter dictionary with options.
86
+ """
87
+ choices: dict[str, list[dict[str, str]]] = {}
88
+ multiple_items_allowed_dict: dict = {}
89
+ is_provider_specific = False
90
+ available_providers: set = set()
91
+ unique_general_choices: list = []
92
+
93
+ # Handle provider-specific choices
94
+ for provider in providers:
95
+ if (provider in p_schema) or (len(providers) == 1):
96
+ is_provider_specific = True
97
+ provider_choices: list = []
98
+ if provider not in available_providers:
99
+ available_providers.add(provider)
100
+ if provider in p_schema:
101
+ provider_choices = p_schema[provider].get("choices", [])
102
+ elif len(providers) == 1 and "enum" in p_schema:
103
+ provider_choices = p_schema["enum"]
104
+ p_schema.pop("enum")
105
+ if provider_choices:
106
+ choices[provider] = [
107
+ {"label": str(c), "value": c} for c in provider_choices
108
+ ]
109
+ if provider in p_schema and p_schema[provider].get(
110
+ "multiple_items_allowed", False
111
+ ):
112
+ multiple_items_allowed_dict[provider] = True
113
+
114
+ # Check title for provider-specific information
115
+ if "title" in p_schema:
116
+ title_providers = set(p_schema["title"].split(","))
117
+ if title_providers.intersection(providers):
118
+ is_provider_specific = True
119
+ available_providers.update(title_providers)
120
+
121
+ # Handle general choices
122
+ general_choices: list = []
123
+ if "enum" in p_schema:
124
+ general_choices.extend(
125
+ [
126
+ {"label": str(c), "value": c}
127
+ for c in p_schema["enum"]
128
+ if c not in ["null", None]
129
+ ]
130
+ )
131
+ elif "anyOf" in p_schema:
132
+ for sub_schema in p_schema["anyOf"]:
133
+ if "enum" in sub_schema:
134
+ general_choices.extend(
135
+ [
136
+ {"label": str(c), "value": c}
137
+ for c in sub_schema["enum"]
138
+ if c not in ["null", None]
139
+ ]
140
+ )
141
+
142
+ if general_choices:
143
+ # Remove duplicates by converting list of dicts to a set of tuples and back to list of dicts
144
+ unique_general_choices = [
145
+ dict(t) for t in {tuple(d.items()) for d in general_choices}
146
+ ]
147
+ if not is_provider_specific:
148
+ if len(providers) == 1:
149
+ choices[providers[0]] = unique_general_choices
150
+ multiple_items_allowed_dict[providers[0]] = p_schema.get(
151
+ "multiple_items_allowed", False
152
+ )
153
+ else:
154
+ choices["other"] = unique_general_choices
155
+ multiple_items_allowed_dict["other"] = p_schema.get(
156
+ "multiple_items_allowed", False
157
+ )
158
+
159
+ # Use general choices as fallback for providers without specific options
160
+ for provider in available_providers:
161
+ if provider not in choices:
162
+ if "anyOf" in p_schema and p_schema["anyOf"]:
163
+ fallback_choices = p_schema["anyOf"][0].get("enum", [])
164
+ choices[provider] = [
165
+ {"label": str(c), "value": c}
166
+ for c in fallback_choices
167
+ if c not in ["null", None]
168
+ ]
169
+ else:
170
+ choices[provider] = unique_general_choices
171
+
172
+ p["options"] = choices
173
+ p["multiple_items_allowed"] = multiple_items_allowed_dict
174
+
175
+ if is_provider_specific:
176
+ p["available_providers"] = list(available_providers)
177
+
178
+ return p
179
+
180
+
181
+ def process_parameter(param: dict, providers: list[str]) -> dict:
182
+ """
183
+ Process a single parameter and return the processed dictionary.
184
+
185
+ Parameters
186
+ ----------
187
+ param : Dict
188
+ Parameter dictionary.
189
+ providers : List[str]
190
+ List of provider options.
191
+
192
+ Returns
193
+ -------
194
+ Dict
195
+ Processed parameter dictionary.
196
+ """
197
+ p: dict = {}
198
+ param_name = param["name"]
199
+ p["parameter_name"] = param_name
200
+ p["label"] = (
201
+ param_name.replace("_", " ").replace("fixedincome", "fixed income").title()
202
+ )
203
+ p["description"] = (
204
+ (param.get("description", param_name).split(" (provider:")[0].strip())
205
+ .split("Multiple comma separated items allowed")[0]
206
+ .strip()
207
+ )
208
+ p["optional"] = param.get("required", False) is False
209
+
210
+ if param_name == "provider":
211
+ p["type"] = "text"
212
+ p["label"] = "Provider"
213
+ p["description"] = "Source of the data."
214
+ p["available_providers"] = providers
215
+ return p
216
+
217
+ if param_name in ["symbol", "series_id", "release_id"]:
218
+ p["type"] = "text"
219
+ p["label"] = param_name.title().replace("_", " ").replace("Id", "ID")
220
+ p["description"] = (
221
+ p["description"]
222
+ .split("Multiple comma separated items allowed for provider(s)")[0]
223
+ .strip()
224
+ )
225
+ multiple_items_allowed_dict: dict = {}
226
+ for _provider in providers:
227
+ if _provider in param["schema"] and param["schema"][_provider].get(
228
+ "multiple_items_allowed", False
229
+ ):
230
+ multiple_items_allowed_dict[_provider] = True
231
+ p["multiple_items_allowed"] = multiple_items_allowed_dict
232
+ if "Multiple comma separated items allowed" in p["description"]:
233
+ p["description"] = (
234
+ p["description"]
235
+ .split("Multiple comma separated items allowed")[0]
236
+ .strip()
237
+ )
238
+ return p
239
+
240
+ p_schema = param.get("schema", {})
241
+ p["value"] = p_schema.get("default", None)
242
+ p = set_parameter_options(p, p_schema, providers)
243
+ p = set_parameter_type(p, p_schema)
244
+
245
+ return p
246
+
247
+
248
+ def get_query_schema_for_widget(
249
+ openapi_json: dict, command_route: str
250
+ ) -> tuple[list[dict], bool]:
251
+ """
252
+ Extract the query schema for a widget.
253
+
254
+ Parameters
255
+ ----------
256
+ openapi_json : dict
257
+ The OpenAPI specification as a dictionary.
258
+ command_route : str
259
+ The route of the command in the OpenAPI specification.
260
+
261
+ Returns
262
+ -------
263
+ Tuple[List[Dict], bool]
264
+ A tuple containing the list of processed parameters and a boolean indicating if a chart is present.
265
+ """
266
+ has_chart = False
267
+ command = openapi_json["paths"][command_route]
268
+ schema_method = list(command)[0]
269
+ command = command[schema_method]
270
+ params = command.get("parameters", [])
271
+ route_params: list[dict] = []
272
+ providers: list[str] = extract_providers(params)
273
+
274
+ for param in params:
275
+ if param["name"] in ["sort", "order"]:
276
+ continue
277
+ if param["name"] == "chart":
278
+ has_chart = True
279
+ continue
280
+
281
+ p = process_parameter(param, providers)
282
+ p["show"] = True
283
+ route_params.append(p)
284
+
285
+ return route_params, has_chart
286
+
287
+
288
+ def get_data_schema_for_widget(openapi_json, operation_id):
289
+ """
290
+ Get the data schema for a widget based on its operationId.
291
+
292
+ Args:
293
+ openapi (dict): The OpenAPI specification as a dictionary.
294
+ operation_id (str): The operationId of the widget.
295
+
296
+ Returns:
297
+ dict: The schema dictionary for the widget's data.
298
+ """
299
+ # Find the route and method for the given operationId
300
+ for _, methods in openapi_json["paths"].items():
301
+ for _, details in methods.items():
302
+ if details.get("operationId") == operation_id:
303
+ # Get the reference to the schema from the successful response
304
+ response_ref = details["responses"]["200"]["content"][
305
+ "application/json"
306
+ ]["schema"]["$ref"]
307
+ # Extract the schema name from the reference
308
+ schema_name = response_ref.split("/")[-1]
309
+ # Fetch and return the schema from components
310
+ return (
311
+ openapi_json["components"]["schemas"][schema_name]
312
+ .get("properties", {})
313
+ .get("results", {})
314
+ )
315
+
316
+ # Return None if the schema is not found
317
+ return None
318
+
319
+
320
+ def data_schema_to_columns_defs(openapi_json, operation_id, provider):
321
+ """Convert data schema to column definitions for the widget."""
322
+ # Initialize an empty list to hold the schema references
323
+ schema_refs: list = []
324
+
325
+ result_schema_ref = get_data_schema_for_widget(openapi_json, operation_id)
326
+ # Check if 'anyOf' is in the result_schema_ref and handle the nested structure
327
+ if "anyOf" in result_schema_ref:
328
+ for item in result_schema_ref["anyOf"]:
329
+ # When there are multiple providers a 'oneOf' is used
330
+ if "items" in item and "oneOf" in item["items"]:
331
+ # Extract the $ref values
332
+ schema_refs.extend(
333
+ [
334
+ oneOf_item["$ref"].split("/")[-1]
335
+ for oneOf_item in item["items"]["oneOf"]
336
+ if "$ref" in oneOf_item
337
+ ]
338
+ )
339
+ # When there's only one model there is no oneOf
340
+ elif "items" in item and "$ref" in item["items"]:
341
+ schema_refs.append(item["items"]["$ref"].split("/")[-1])
342
+
343
+ # Fetch the schemas using the extracted references
344
+ schemas = [
345
+ openapi_json["components"]["schemas"][ref]
346
+ for ref in schema_refs
347
+ if ref in openapi_json["components"]["schemas"]
348
+ ]
349
+
350
+ # Proceed with finding common keys and generating column definitions
351
+ if not schemas:
352
+ return []
353
+
354
+ target_schema: dict = {}
355
+
356
+ if len(schemas) == 1:
357
+ target_schema = schemas[0]
358
+ else:
359
+ for schema in schemas:
360
+ if (
361
+ schema.get("description", "")
362
+ .lower()
363
+ .startswith(provider.lower().replace("tradingeconomics", "te"))
364
+ ) or (schema.get("description", "").lower().startswith("us government")):
365
+ target_schema = schema
366
+ break
367
+
368
+ keys = list(target_schema.get("properties", {}))
369
+
370
+ column_defs: list = []
371
+ for key in keys:
372
+ cell_data_type = None
373
+ formatterFn = None
374
+ prop = target_schema.get("properties", {}).get(key)
375
+ # Handle prop types for both when there's a single prop type or multiple
376
+ if "anyOf" in prop:
377
+ types = [
378
+ sub_prop.get("type") for sub_prop in prop["anyOf"] if "type" in sub_prop
379
+ ]
380
+ if "number" in types or "integer" in types or "float" in types:
381
+ cell_data_type = "number"
382
+ elif "string" in types and any(
383
+ sub_prop.get("format") in ["date", "date-time"]
384
+ for sub_prop in prop["anyOf"]
385
+ if "format" in sub_prop
386
+ ):
387
+ cell_data_type = "date"
388
+ else:
389
+ cell_data_type = "text"
390
+ else:
391
+ prop_type = prop.get("type", None)
392
+ if prop_type in ["number", "integer", "float"]:
393
+ cell_data_type = "number"
394
+ if prop_type == "integer":
395
+ formatterFn = "int"
396
+ elif "format" in prop and prop["format"] in ["date", "date-time"]:
397
+ cell_data_type = "date"
398
+ else:
399
+ cell_data_type = "text"
400
+
401
+ column_def: dict = {}
402
+ # OpenAPI changes some of the field names.
403
+ k = to_snake_case(key)
404
+ column_def["field"] = k
405
+ if k in [
406
+ "symbol",
407
+ "symbol_root",
408
+ "series_id",
409
+ "date",
410
+ "published",
411
+ "fiscal_year",
412
+ "period_ending",
413
+ "period_beginning",
414
+ "order",
415
+ "name",
416
+ "title",
417
+ "cusip",
418
+ "isin",
419
+ ]:
420
+ column_def["pinned"] = "left"
421
+
422
+ column_def["formatterFn"] = formatterFn
423
+ column_def["headerName"] = prop.get("title", key.title())
424
+ column_def["description"] = prop.get(
425
+ "description", prop.get("title", key.title())
426
+ )
427
+ column_def["cellDataType"] = cell_data_type
428
+ column_def["chartDataType"] = (
429
+ "series"
430
+ if cell_data_type in ["number", "integer", "float"]
431
+ and column_def.get("pinned") != "left"
432
+ else "category"
433
+ )
434
+ measurement = prop.get("x-unit_measurement")
435
+
436
+ if measurement == "percent":
437
+ column_def["formatterFn"] = (
438
+ "normalizedPercent"
439
+ if prop.get("x-frontend_multiply") == 100
440
+ else "percent"
441
+ )
442
+ column_def["renderFn"] = "greenRed"
443
+ elif cell_data_type == "number":
444
+ del column_def["formatterFn"]
445
+
446
+ if k in ["cik", "isin", "figi", "cusip", "sedol", "symbol"]:
447
+ column_def["cellDataType"] = "text"
448
+ column_def["headerName"] = (
449
+ column_def["headerName"].upper() if k != "symbol" else "Symbol"
450
+ )
451
+
452
+ column_defs.append(column_def)
453
+
454
+ return column_defs
@@ -0,0 +1,240 @@
1
+ """Utils for building the widgets.json file."""
2
+
3
+ from copy import deepcopy
4
+
5
+ TO_CAPS_STRINGS = [
6
+ "Pe",
7
+ "Sloos",
8
+ "Eps",
9
+ "Ebitda",
10
+ "Otc",
11
+ "Cpi",
12
+ "Pce",
13
+ "Gdp",
14
+ "Lbma",
15
+ "Ipo",
16
+ "Nbbo",
17
+ "Ameribor",
18
+ "Sonia",
19
+ "Effr",
20
+ "Sofr",
21
+ "Iorb",
22
+ "Estr",
23
+ "Ecb",
24
+ "Dpcredit",
25
+ "Tcm",
26
+ "Us",
27
+ "Ice",
28
+ "Bofa",
29
+ "Hqm",
30
+ "Sp500",
31
+ "Sec",
32
+ "Cftc",
33
+ "Cot",
34
+ "Etf",
35
+ "Eu",
36
+ "Tips",
37
+ "Rss",
38
+ "Sic",
39
+ "Cik",
40
+ "Bls",
41
+ "Fred",
42
+ "Cusip",
43
+ ]
44
+
45
+
46
+ def modify_query_schema(query_schema: list[dict], provider_value: str):
47
+ """Modify query_schema and the description for the current provider."""
48
+ modified_query_schema: list = []
49
+ for item in query_schema:
50
+ # copy the item
51
+ _item = deepcopy(item)
52
+ provider_value_options: dict = {}
53
+
54
+ # Exclude provider parameter. Those will be added last.
55
+ if "parameter_name" in _item and _item["parameter_name"] == "provider":
56
+ continue
57
+
58
+ # Exclude parameters that are not available for the current provider.
59
+ if (
60
+ "available_providers" in _item
61
+ and provider_value not in _item["available_providers"]
62
+ ):
63
+ continue
64
+
65
+ if provider_value in _item["multiple_items_allowed"] and _item[
66
+ "multiple_items_allowed"
67
+ ].get(provider_value, False):
68
+ _item["description"] = (
69
+ _item["description"] + " Multiple comma separated items allowed."
70
+ )
71
+ _item["type"] = "text"
72
+ _item["multiSelect"] = True
73
+
74
+ if "options" in _item:
75
+ provider_value_options = _item.pop("options")
76
+
77
+ if provider_value in provider_value_options and bool(
78
+ provider_value_options[provider_value]
79
+ ):
80
+ _item["options"] = provider_value_options[provider_value]
81
+ _item["type"] = "text"
82
+ elif len(provider_value_options) == 1 and "other" in provider_value_options:
83
+ _item["options"] = provider_value_options["other"]
84
+ _item["type"] = "text"
85
+
86
+ _item.pop("multiple_items_allowed")
87
+
88
+ if "available_providers" in _item:
89
+ _item.pop("available_providers")
90
+
91
+ _item["paramName"] = _item.pop("parameter_name")
92
+
93
+ modified_query_schema.append(_item)
94
+
95
+ if provider_value != "custom":
96
+ modified_query_schema.append(
97
+ {"paramName": "provider", "value": provider_value, "show": False}
98
+ )
99
+
100
+ return modified_query_schema
101
+
102
+
103
+ def build_json(openapi: dict, widget_exclude_filter: list):
104
+ """Build the widgets.json file."""
105
+ # pylint: disable=import-outside-toplevel
106
+ from .openapi import data_schema_to_columns_defs, get_query_schema_for_widget
107
+
108
+ if not openapi:
109
+ return {}
110
+
111
+ widgets_json: dict = {}
112
+ routes = [
113
+ p
114
+ for p in openapi["paths"]
115
+ if p.startswith("/api") and "get" in openapi["paths"][p]
116
+ ]
117
+ for route in routes:
118
+ route_api = openapi["paths"][route]
119
+ method = list(route_api)[0]
120
+ widget_id = route_api[method]["operationId"]
121
+
122
+ # Prepare the query schema of the widget
123
+ query_schema, has_chart = get_query_schema_for_widget(openapi, route)
124
+
125
+ # Extract providers from the query schema
126
+ providers: list = []
127
+ for item in query_schema:
128
+ if item["parameter_name"] == "provider":
129
+ providers = item["available_providers"]
130
+
131
+ if not providers:
132
+ providers = ["custom"]
133
+
134
+ for provider in providers:
135
+ columns_defs = data_schema_to_columns_defs(openapi, widget_id, provider)
136
+ _cat = route.split("v1/")[-1]
137
+ _cats = _cat.split("/")
138
+ category = _cats[0].title()
139
+ category = category.replace("Fixedincome", "Fixed Income")
140
+ subcat = _cats[1].title().replace("_", " ") if len(_cats) > 2 else None
141
+ name = (
142
+ widget_id.replace("fixedincome", "fixed income")
143
+ .replace("_", " ")
144
+ .title()
145
+ )
146
+
147
+ name = " ".join(
148
+ [
149
+ (word.upper() if word in TO_CAPS_STRINGS else word)
150
+ for word in name.split()
151
+ ]
152
+ )
153
+
154
+ modified_query_schema = modify_query_schema(query_schema, provider)
155
+
156
+ provider_map = {
157
+ "tmx": "TMX",
158
+ "ecb": "ECB",
159
+ "econdb": "EconDB",
160
+ "fmp": "FMP",
161
+ "oecd": "OECD",
162
+ "finra": "FINRA",
163
+ "fred": "FRED",
164
+ "imf": "IMF",
165
+ "bls": "BLS",
166
+ "yfinance": "yFinance",
167
+ "sec": "SEC",
168
+ "cftc": "CFTC",
169
+ "tradingeconomics": "Trading Economics",
170
+ "wsj": "WSJ",
171
+ }
172
+ provider_name = provider_map.get(
173
+ provider.lower(), provider.replace("_", " ").title()
174
+ )
175
+
176
+ widget_config = {
177
+ "name": f"{name} ({provider_name})",
178
+ "description": route_api["get"]["description"],
179
+ "category": category,
180
+ "searchCategory": category,
181
+ "widgetId": f"{widget_id}_{provider}_obb",
182
+ "params": modified_query_schema,
183
+ "endpoint": route.replace("/api", "api"),
184
+ "gridData": {"w": 45, "h": 15},
185
+ "data": {
186
+ "dataKey": "results",
187
+ "table": {
188
+ "showAll": True,
189
+ },
190
+ },
191
+ "source": [provider_name],
192
+ }
193
+
194
+ if subcat:
195
+ subcat = " ".join(
196
+ [
197
+ (word.upper() if word in TO_CAPS_STRINGS else word)
198
+ for word in subcat.split()
199
+ ]
200
+ )
201
+ subcat = (
202
+ subcat.replace("Estimates", "Analyst Estimates")
203
+ .replace("Fundamental", "Fundamental Analysis")
204
+ .replace("Compare", "Comparison Analysis")
205
+ )
206
+ widget_config["subCategory"] = subcat
207
+
208
+ if columns_defs:
209
+ widget_config["data"]["table"]["columnsDefs"] = columns_defs
210
+
211
+ # Add the widget configuration to the widgets.json
212
+ if widget_config["widgetId"] not in widget_exclude_filter:
213
+ widgets_json[widget_config["widgetId"]] = widget_config
214
+
215
+ if has_chart:
216
+ widget_config_chart = deepcopy(widget_config)
217
+ widget_config_chart["name"] = widget_config_chart["name"] + " (Chart)"
218
+ widget_config_chart["widgetId"] = (
219
+ f"{widget_config_chart['widgetId']}_chart"
220
+ )
221
+ widget_config_chart["params"].append(
222
+ {
223
+ "paramName": "chart",
224
+ "label": "Chart",
225
+ "description": "Returns chart",
226
+ "optional": True,
227
+ "value": True,
228
+ "type": "boolean",
229
+ "show": False,
230
+ },
231
+ )
232
+ widget_config_chart["searchCategory"] = "chart"
233
+ widget_config_chart["gridData"]["h"] = 20
234
+ widget_config_chart["gridData"]["w"] = 50
235
+ widget_config_chart["defaultViz"] = "chart"
236
+ widget_config_chart["data"]["dataKey"] = "chart.content"
237
+ if widget_config_chart["widgetId"] not in widget_exclude_filter:
238
+ widgets_json[widget_config_chart["widgetId"]] = widget_config_chart
239
+
240
+ return widgets_json
@@ -0,0 +1,27 @@
1
+ [tool.poetry]
2
+ name = "openbb-platform-api"
3
+ version = "1.0.0"
4
+ description = "OpenBB Platform API: Launch script and widgets builder for the OpenBB Platform API and Terminal Pro Connector."
5
+ authors = ["OpenBB <hello@openbb.co>"]
6
+ license = "AGPL-3.0-only"
7
+ readme = "README.md"
8
+ homepage = "https://openbb.co"
9
+ repository = "https://github.com/openbb-finance/openbb"
10
+ documentation = "https://docs.openbb.co"
11
+ packages = [{ include = "openbb_platform_api" }]
12
+
13
+ [tool.poetry.scripts]
14
+ openbb-api = "openbb_platform_api.main:main"
15
+
16
+ [tool.poetry.dependencies]
17
+ python = ">=3.9,<3.13"
18
+ poetry = "^1.8"
19
+ setuptools = "*"
20
+ openbb-core = "*"
21
+ deepdiff = "*"
22
+ ruff = "*"
23
+ black = "*"
24
+
25
+ [build-system]
26
+ requires = ["poetry-core>=1.0.0"]
27
+ build-backend = "poetry.core.masonry.api"