From 932a895a06985f9fc8151a5f57d6677185221bc2 Mon Sep 17 00:00:00 2001
From: Danglewood <85772166+deeleeramone@users.noreply.github.com>
Date: Fri, 25 Sep 2026 10:58:33 -0700
Subject: [PATCH] fix cli renderer helper with figure object JSON
---
cli/README.md | 5 +-
cli/conftest.py | 77 +++++---
cli/openbb_cli/assets/figure.html | 33 ++++
.../controllers/base_platform_controller.py | 62 +++++-
cli/openbb_cli/dispatchers/openapi_schema.py | 62 +++---
cli/openbb_cli/outputs/figure.py | 113 +++++++++++
cli/openbb_cli/outputs/html.py | 5 +
cli/openbb_cli/outputs/rich.py | 4 +
cli/openbb_cli/outputs/tsv.py | 7 +
cli/tests/test_codegen_package_gen.py | 16 ++
cli/tests/test_codegen_post_gen.py | 17 ++
...st_controllers_base_platform_controller.py | 77 ++++++++
cli/tests/test_dispatchers_openapi_schema.py | 183 ++++++++++++++++++
cli/tests/test_outputs_figure.py | 114 +++++++++++
cli/tests/test_outputs_html.py | 14 ++
cli/tests/test_outputs_rich.py | 14 ++
cli/tests/test_outputs_tsv.py | 13 ++
.../app/static/package_builder/builder.py | 22 ++-
.../tests/app/static/test_package_builder.py | 16 ++
19 files changed, 789 insertions(+), 65 deletions(-)
create mode 100644 cli/openbb_cli/assets/figure.html
create mode 100644 cli/openbb_cli/outputs/figure.py
create mode 100644 cli/tests/test_outputs_figure.py
diff --git a/cli/README.md b/cli/README.md
index a01bee63f5e..83f1d414936 100644
--- a/cli/README.md
+++ b/cli/README.md
@@ -239,10 +239,13 @@ The hook receives an `AuthContext` (namespace, command, params, method) and retu
from openbb_cli.auth import AuthContext, AuthDecision
from myapp.identity import current_user, get_token
+
def rbac_hook(ctx: AuthContext) -> AuthDecision:
user = current_user()
if not user.can_access(ctx.namespace, ctx.command):
- return AuthDecision(allow=False, deny_reason=f"{user.role} cannot call {ctx.command}")
+ return AuthDecision(
+ allow=False, deny_reason=f"{user.role} cannot call {ctx.command}"
+ )
return AuthDecision(headers={"Authorization": f"Bearer {get_token(user)}"})
```
diff --git a/cli/conftest.py b/cli/conftest.py
index faca3bdd98d..af0b2602e5e 100644
--- a/cli/conftest.py
+++ b/cli/conftest.py
@@ -238,39 +238,60 @@ def _combine_partial_coverage() -> None:
partial.unlink()
+OBB_WORKER = """\
+import contextlib, io, json, sys, traceback
+from openbb import obb
+out = sys.stdout
+for line in sys.stdin:
+ namespace = {"obb": obb}
+ try:
+ with contextlib.redirect_stdout(io.StringIO()):
+ exec(json.loads(line), namespace)
+ response = {"ok": True, "result": namespace["RESULT"]}
+ except Exception:
+ response = {"ok": False, "error": traceback.format_exc()}
+ out.write(json.dumps(response, default=str) + "\\n")
+ out.flush()
+"""
+
+
@pytest.fixture(scope="session")
-def run_in_obb(cli_fake_extension_installed): # noqa: ARG001
- """Run an inline snippet under a fresh subprocess that imports ``openbb``.
+def run_in_obb(cli_fake_extension_installed, tmp_path_factory): # noqa: ARG001
+ """Run inline snippets in one session-wide subprocess that imports ``openbb`` once.
Returns a callable ``run(snippet: str) -> dict``. The snippet must assign
- a JSON-serializable value to ``RESULT``; the wrapper ``json.dumps`` it to
- stdout and the helper parses it back into Python.
-
- Use this for any test that needs to resolve commands via the real
- generated static ``obb`` namespace — entry-point caching makes
- in-process resolution unreliable after a fresh build.
+ a JSON-serializable value to ``RESULT``. Each snippet executes in a fresh
+ namespace inside a worker started after the session build, so commands
+ resolve through the real generated static ``obb`` namespace without
+ re-importing it for every test.
"""
-
- def _run(snippet: str) -> dict:
- wrapper = textwrap.dedent("""\
- import json, sys
- from openbb import obb # noqa: F401
- {snippet}
- sys.stdout.write(json.dumps(RESULT, default=str))
- """).format(snippet=textwrap.dedent(snippet).strip())
- proc = subprocess.run( # noqa: S603
- [sys.executable, "-c", wrapper],
- check=False,
- capture_output=True,
+ stderr_path = tmp_path_factory.mktemp("obb_worker") / "stderr.log"
+ with stderr_path.open("w", encoding="utf-8") as stderr:
+ worker = subprocess.Popen( # noqa: S603
+ [sys.executable, "-c", OBB_WORKER],
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=stderr,
text=True,
env=_subprocess_env({"OPENBB_AUTO_BUILD": "false"}),
)
- if proc.returncode != 0:
- raise AssertionError(
- f"obb subprocess failed (rc={proc.returncode})\n"
- f"--- stdout ---\n{proc.stdout}\n"
- f"--- stderr ---\n{proc.stderr}"
- )
- return json.loads(proc.stdout)
- return _run
+ def _run(snippet: str) -> dict:
+ worker.stdin.write(json.dumps(textwrap.dedent(snippet).strip()) + "\n")
+ worker.stdin.flush()
+ line = worker.stdout.readline()
+ if not line:
+ raise AssertionError(
+ f"obb worker exited (rc={worker.wait()})\n"
+ f"--- stderr ---\n{stderr_path.read_text(encoding='utf-8')}"
+ )
+ response = json.loads(line)
+ if not response["ok"]:
+ raise AssertionError(f"obb snippet failed\n{response['error']}")
+ return response["result"]
+
+ try:
+ yield _run
+ finally:
+ worker.stdin.close()
+ worker.wait(timeout=60)
diff --git a/cli/openbb_cli/assets/figure.html b/cli/openbb_cli/assets/figure.html
new file mode 100644
index 00000000000..1b6ceb3b279
--- /dev/null
+++ b/cli/openbb_cli/assets/figure.html
@@ -0,0 +1,33 @@
+
+
+
+
+ __TITLE__
+ __PLOTLYJS__
+
+
+
+
+
+
+
diff --git a/cli/openbb_cli/controllers/base_platform_controller.py b/cli/openbb_cli/controllers/base_platform_controller.py
index af1a9b4ad24..3fb70362f22 100644
--- a/cli/openbb_cli/controllers/base_platform_controller.py
+++ b/cli/openbb_cli/controllers/base_platform_controller.py
@@ -10,11 +10,65 @@ import pandas as pd
from openbb_cli.config.menu_text import MenuText
from openbb_cli.controllers.base_controller import BaseController
from openbb_cli.controllers.utils import export_data, print_rich_table
+from openbb_cli.outputs.figure import is_plotly_figure
from openbb_cli.session import Session
session = Session()
+def _is_empty_result(result: object) -> bool:
+ """Return whether a command result is ``None`` or an empty collection.
+
+ Parameters
+ ----------
+ result : object
+ The command result.
+
+ Returns
+ -------
+ bool
+ True for ``None`` and for empty lists, tuples, and dicts.
+ """
+ return result is None or (isinstance(result, (list, tuple, dict)) and not result)
+
+
+def _display_dict_result(result: dict, title: str, export: bool) -> pd.DataFrame:
+ """Display a dict command result, rendering Plotly figure JSON as a chart.
+
+ Parameters
+ ----------
+ result : dict
+ The command result.
+ title : str
+ The command location used as the display title.
+ export : bool
+ Whether the result is being exported instead of displayed.
+
+ Returns
+ -------
+ pd.DataFrame
+ The tabular form of the result for export, empty for figures and
+ results that cannot be tabulated.
+ """
+ if is_plotly_figure(result):
+ if export:
+ session.console.print(
+ "[yellow]This command returns a chart. "
+ "Add --raw to export its data.[/yellow]"
+ )
+ else:
+ session.output_adapter.display(data=result, title=title, chart=True)
+ return pd.DataFrame()
+ try:
+ df = pd.DataFrame.from_dict(result, orient="columns")
+ except ValueError:
+ if not export:
+ session.console.print(result)
+ return pd.DataFrame()
+ print_rich_table(df=df, show_index=True, title=title, export=export)
+ return df
+
+
@lru_cache(maxsize=1)
def _OBBject() -> type:
"""Return ``OBBject`` lazily."""
@@ -281,14 +335,14 @@ class PlatformController(BaseController):
session.console.print(results)
elif isinstance(obbject, dict):
- df = pd.DataFrame.from_dict(obbject, orient="columns")
- print_rich_table(
- df=df, show_index=True, title=title, export=export
- )
+ df = _display_dict_result(obbject, title, export)
elif not isinstance(obbject, _OBBject()):
session.console.print(obbject)
+ elif not export and _is_empty_result(obbject):
+ session.console.print("[yellow]No results.[/yellow]")
+
if export and not df.empty:
sheet_name = getattr(ns_parser, "sheet_name", None)
if sheet_name and isinstance(sheet_name, list):
diff --git a/cli/openbb_cli/dispatchers/openapi_schema.py b/cli/openbb_cli/dispatchers/openapi_schema.py
index feb21647a56..c1b89a10f51 100644
--- a/cli/openbb_cli/dispatchers/openapi_schema.py
+++ b/cli/openbb_cli/dispatchers/openapi_schema.py
@@ -5,7 +5,7 @@ from __future__ import annotations
import argparse
import json
import re
-from typing import Any
+from typing import Any, NoReturn
from urllib.parse import unquote, urldefrag, urljoin, urlsplit
import httpx
@@ -840,6 +840,31 @@ def _bundle_external_refs(
)
+def _raise_fetch_failure(
+ response: Any, parse_error: ValueError | None, url: str
+) -> NoReturn:
+ """Raise why the spec at ``url`` could not be loaded.
+
+ Parameters
+ ----------
+ response : Any
+ The response for the spec URL.
+ parse_error : ValueError | None
+ The error from parsing a successful response, if any.
+ url : str
+ The spec URL.
+
+ Raises
+ ------
+ httpx.HTTPStatusError
+ If the response has an error status.
+ ValueError
+ If the response body is not a parseable OpenAPI document.
+ """
+ response.raise_for_status()
+ raise parse_error or ValueError(f"Could not load an OpenAPI document from {url}")
+
+
def fetch_openapi(
base_url: str,
*,
@@ -866,6 +891,7 @@ def fetch_openapi(
headers=merged_headers,
params=query_params or None,
)
+ parse_error: ValueError | None = None
if response.status_code < 400:
try:
parsed = _ensure_openapi_dict(
@@ -881,24 +907,11 @@ def fetch_openapi(
timeout=timeout,
headers=merged_headers,
)
- except (json.JSONDecodeError, ValueError):
- pass
+ except ValueError as exc:
+ parse_error = exc
if explicit_path:
- response.raise_for_status()
- parsed = _ensure_openapi_dict(
- _parse_spec_text(
- response.text,
- content_type=response.headers.get("content-type", ""),
- ),
- full_url,
- )
- return _bundle_external_refs(
- parsed,
- str(getattr(response, "url", full_url)),
- timeout=timeout,
- headers=merged_headers,
- )
+ _raise_fetch_failure(response, parse_error, full_url)
landing_url = base_url.rstrip("/") + "/"
landing = httpx.get(
@@ -918,20 +931,7 @@ def fetch_openapi(
headers=merged_headers,
)
- response.raise_for_status()
- parsed = _ensure_openapi_dict(
- _parse_spec_text(
- response.text,
- content_type=response.headers.get("content-type", ""),
- ),
- full_url,
- )
- return _bundle_external_refs(
- parsed,
- str(getattr(response, "url", full_url)),
- timeout=timeout,
- headers=merged_headers,
- )
+ _raise_fetch_failure(response, parse_error, full_url)
def _ensure_openapi_dict(parsed: Any, source_url: str) -> dict[str, Any]:
diff --git a/cli/openbb_cli/outputs/figure.py b/cli/openbb_cli/outputs/figure.py
new file mode 100644
index 00000000000..dacd406552e
--- /dev/null
+++ b/cli/openbb_cli/outputs/figure.py
@@ -0,0 +1,113 @@
+"""Display Plotly figure JSON returned by chart commands."""
+
+from __future__ import annotations
+
+import html
+import json
+import re
+import tempfile
+import webbrowser
+from pathlib import Path
+from typing import Any
+
+from openbb_cli.session import Session
+
+session = Session()
+
+FIGURE_TEMPLATE = Path(__file__).parent.parent / "assets" / "figure.html"
+PLOTLYJS_CDN = "https://cdn.jsdelivr.net/npm/plotly.js-dist-min@4/plotly.min.js"
+_TEMPLATE_TOKEN_RE = re.compile(r"__(TITLE|PLOTLYJS|FIGURE)__")
+
+
+def is_plotly_figure(data: Any) -> bool:
+ """Return whether a command result is Plotly figure JSON.
+
+ Parameters
+ ----------
+ data : Any
+ The command result.
+
+ Returns
+ -------
+ bool
+ True for a dict with a ``data`` list and a ``layout`` dict.
+ """
+ return (
+ isinstance(data, dict)
+ and isinstance(data.get("data"), list)
+ and isinstance(data.get("layout"), dict)
+ )
+
+
+def _plotlyjs_tag() -> str:
+ """Return the script tag that loads plotly.js, inlined when plotly is installed.
+
+ Returns
+ -------
+ str
+ An inline ``'
+ return f""
+
+
+def render_figure_html(figure: dict, title: str) -> str:
+ """Render figure JSON as a standalone HTML page.
+
+ Parameters
+ ----------
+ figure : dict
+ Plotly figure JSON with ``data``, ``layout``, and optional ``config``.
+ title : str
+ The page title.
+
+ Returns
+ -------
+ str
+ The HTML document.
+ """
+ values = {
+ "TITLE": html.escape(title),
+ "PLOTLYJS": _plotlyjs_tag(),
+ "FIGURE": json.dumps(figure).replace("", "<\\/"),
+ }
+ template = FIGURE_TEMPLATE.read_text(encoding="utf-8")
+ return _TEMPLATE_TOKEN_RE.sub(lambda match: values[match.group(1)], template)
+
+
+def show_figure(figure: dict, title: str = "") -> None:
+ """Show figure JSON in the charting window, or in the browser without one.
+
+ Parameters
+ ----------
+ figure : dict
+ Plotly figure JSON with ``data``, ``layout``, and optional ``config``.
+ title : str
+ The command location shown with the chart.
+ """
+ backend = session.backend
+ if backend is not None:
+ try:
+ from plotly.graph_objects import Figure
+
+ backend.send_figure(
+ Figure(data=figure["data"], layout=figure["layout"]),
+ command_location=title,
+ )
+ return
+ except Exception as e:
+ session.console.print(
+ f"[yellow]Chart window not available, opening in browser: {e}[/yellow]"
+ )
+
+ with tempfile.NamedTemporaryFile(
+ mode="w", delete=False, suffix=".html", encoding="utf-8"
+ ) as f:
+ f.write(render_figure_html(figure, title))
+ path = Path(f.name)
+
+ webbrowser.open(path.as_uri())
+ session.console.print(f"[green]Opened chart in browser: {path}[/green]")
diff --git a/cli/openbb_cli/outputs/html.py b/cli/openbb_cli/outputs/html.py
index f01d6c39210..066d1a132ad 100644
--- a/cli/openbb_cli/outputs/html.py
+++ b/cli/openbb_cli/outputs/html.py
@@ -7,6 +7,7 @@ from typing import Any
import pandas as pd
+from openbb_cli.outputs.figure import is_plotly_figure, show_figure
from openbb_cli.session import Session
session = Session()
@@ -26,6 +27,10 @@ class HtmlOutput:
if export:
return
+ if is_plotly_figure(data):
+ show_figure(data, title)
+ return
+
if hasattr(data, "model_dump"):
if session.settings.USE_INTERACTIVE_DF and session.backend is not None:
try:
diff --git a/cli/openbb_cli/outputs/rich.py b/cli/openbb_cli/outputs/rich.py
index 6f2eeb80bc6..b0e96c8dfa2 100644
--- a/cli/openbb_cli/outputs/rich.py
+++ b/cli/openbb_cli/outputs/rich.py
@@ -5,6 +5,7 @@ from typing import Any
import pandas as pd
from openbb_cli.controllers.utils import print_rich_table
+from openbb_cli.outputs.figure import is_plotly_figure, show_figure
from openbb_cli.session import Session
session = Session()
@@ -84,6 +85,9 @@ class RichTableOutput:
return
elif isinstance(data, pd.Series):
df = data.to_frame()
+ elif is_plotly_figure(data):
+ show_figure(data, title)
+ return
elif isinstance(data, dict):
try:
df = pd.DataFrame.from_dict(data, orient="columns")
diff --git a/cli/openbb_cli/outputs/tsv.py b/cli/openbb_cli/outputs/tsv.py
index a1858682361..7befca020c9 100644
--- a/cli/openbb_cli/outputs/tsv.py
+++ b/cli/openbb_cli/outputs/tsv.py
@@ -2,11 +2,14 @@
from __future__ import annotations
+import json
import sys
from typing import Any
import pandas as pd
+from openbb_cli.outputs.figure import is_plotly_figure
+
def _coerce_results(results: Any) -> pd.DataFrame | None:
"""Coerce the ``results`` payload of an OBBject into a DataFrame."""
@@ -50,6 +53,10 @@ class TsvOutput:
return
if chart and hasattr(data, "chart") and data.chart is not None:
return
+ if is_plotly_figure(data):
+ sys.stdout.write(json.dumps(data) + "\n")
+ sys.stdout.flush()
+ return
df = _to_dataframe(data)
if df is not None:
df.to_csv(sys.stdout, sep="\t", index=False)
diff --git a/cli/tests/test_codegen_package_gen.py b/cli/tests/test_codegen_package_gen.py
index ad8e231f27b..a5b26dd7b38 100644
--- a/cli/tests/test_codegen_package_gen.py
+++ b/cli/tests/test_codegen_package_gen.py
@@ -479,3 +479,19 @@ def test_find_ruff_returns_none_when_unavailable(tmp_path, monkeypatch):
monkeypatch.setattr(pkg.sys, "executable", str(fake_python))
monkeypatch.setattr(pkg.shutil, "which", lambda name: None)
assert pkg._find_ruff() is None
+
+
+def test_build_root_router_imports_stream_and_post_processing_functions():
+ router = pkg._build_root_router(
+ package_name="openbb_x",
+ root_namespace="x",
+ sub_routers=[],
+ root_commands=["@router.command()\nasync def live(): ..."],
+ root_post_imports=[("openbb_x.post", "shape_rows")],
+ root_has_stream=True,
+ )
+ assert "from openbb_core.app.model.stream import OBBStream" in router.source
+ assert "from openbb_x.post import shape_rows as _shape_rows" in router.source
+ assert router.source.index("_shape_rows") < router.source.index(
+ 'router = Router(prefix="")'
+ )
diff --git a/cli/tests/test_codegen_post_gen.py b/cli/tests/test_codegen_post_gen.py
index 86662ed7cb7..c62ff5a51ff 100644
--- a/cli/tests/test_codegen_post_gen.py
+++ b/cli/tests/test_codegen_post_gen.py
@@ -797,3 +797,20 @@ def test_generate_post_command_module_no_body_props_uses_empty_body_dict():
)
out = pg.generate_post_command_module(spec)
assert "_body: dict[str, Any] = {}" in out.source
+
+
+def test_envelope_inner_schema_single_object_array_among_other_properties():
+ row = {"type": "object", "properties": {"symbol": {"type": "string"}}}
+ props = {
+ "count": {"type": "integer"},
+ "data": {"type": "array", "items": row},
+ }
+ assert pg._envelope_inner_schema({"type": "object"}, props) == row
+
+
+def test_envelope_inner_schema_single_scalar_array_is_not_an_envelope():
+ props = {
+ "count": {"type": "integer"},
+ "tags": {"type": "array", "items": {"type": "string"}},
+ }
+ assert pg._envelope_inner_schema({"type": "object"}, props) is None
diff --git a/cli/tests/test_controllers_base_platform_controller.py b/cli/tests/test_controllers_base_platform_controller.py
index 88191e95511..3593aeca5f0 100644
--- a/cli/tests/test_controllers_base_platform_controller.py
+++ b/cli/tests/test_controllers_base_platform_controller.py
@@ -667,3 +667,80 @@ def test_print_help_emits_warnings(mock_session):
controller.print_help()
msgs = [str(c) for c in mock_session.console.print.call_args_list]
assert any("cmd: broken" in m for m in msgs)
+
+
+FIGURE_RESULT = {
+ "data": [{"type": "scatter", "x": [1, 2], "y": [3, 4]}],
+ "layout": {"title": {"text": "Chart"}},
+ "config": {"displaylogo": False},
+}
+
+
+def test_generated_call_figure_result_is_rendered_as_chart(mock_session):
+ """Plotly figure JSON goes to the output adapter as a chart, not a table."""
+ controller, translator = _make_command_call_test_setup(
+ mock_session, command_returns=FIGURE_RESULT
+ )
+ ns = MagicMock(export="", register_obbject=False, chart=False, sheet_name=None)
+ controller.parse_known_args_and_warn.return_value = ns
+ with patch(
+ "openbb_cli.controllers.base_platform_controller.print_rich_table"
+ ) as print_table:
+ controller.call_cmd([])
+ mock_session.output_adapter.display.assert_called_once_with(
+ data=FIGURE_RESULT, title="/parent/test/test_command", chart=True
+ )
+ print_table.assert_not_called()
+
+
+def test_generated_call_figure_result_export_points_to_raw(mock_session):
+ """Exporting a chart result asks for ``--raw`` instead of exporting nothing."""
+ controller, translator = _make_command_call_test_setup(
+ mock_session, command_returns=FIGURE_RESULT
+ )
+ ns = MagicMock(export=["csv"], chart=False, sheet_name=None)
+ controller.parse_known_args_and_warn.return_value = ns
+ with patch(
+ "openbb_cli.controllers.base_platform_controller.export_data"
+ ) as export_data:
+ controller.call_cmd([])
+ mock_session.output_adapter.display.assert_not_called()
+ export_data.assert_not_called()
+ msgs = [str(c) for c in mock_session.console.print.call_args_list]
+ assert any("--raw" in m for m in msgs)
+
+
+def test_generated_call_untabulable_dict_is_printed(mock_session):
+ """A dict mixing lists and nested dicts is printed instead of raising."""
+ result = {"rows": [1, 2], "meta": {"source": "x"}}
+ controller, translator = _make_command_call_test_setup(
+ mock_session, command_returns=result
+ )
+ ns = MagicMock(export="", register_obbject=False, chart=False, sheet_name=None)
+ controller.parse_known_args_and_warn.return_value = ns
+ controller.call_cmd([])
+ mock_session.console.print.assert_any_call(result)
+
+
+@pytest.mark.parametrize("empty", [[], {}, (), None])
+def test_generated_call_empty_result_reports_no_results(mock_session, empty):
+ """An empty or missing result says so instead of printing nothing."""
+ controller, translator = _make_command_call_test_setup(
+ mock_session, command_returns=empty
+ )
+ ns = MagicMock(export="", register_obbject=False, chart=False, sheet_name=None)
+ controller.parse_known_args_and_warn.return_value = ns
+ controller.call_cmd([])
+ mock_session.console.print.assert_called_once_with("[yellow]No results.[/yellow]")
+
+
+def test_generated_call_falsy_scalar_result_is_not_reported_empty(mock_session):
+ """A falsy scalar such as ``0`` is a result, not an empty one."""
+ controller, translator = _make_command_call_test_setup(
+ mock_session, command_returns=0
+ )
+ ns = MagicMock(export="", register_obbject=False, chart=False, sheet_name=None)
+ controller.parse_known_args_and_warn.return_value = ns
+ controller.call_cmd([])
+ msgs = [str(c) for c in mock_session.console.print.call_args_list]
+ assert not any("No results" in m for m in msgs)
diff --git a/cli/tests/test_dispatchers_openapi_schema.py b/cli/tests/test_dispatchers_openapi_schema.py
index 1e3bf6836fd..fa5a7ac7254 100644
--- a/cli/tests/test_dispatchers_openapi_schema.py
+++ b/cli/tests/test_dispatchers_openapi_schema.py
@@ -1873,3 +1873,186 @@ def test_bundle_external_refs_rejects_schema_ref_siblings(monkeypatch):
timeout=1,
headers={},
)
+
+
+def _serve_documents(monkeypatch, documents):
+ from openbb_cli.dispatchers import openapi_schema
+
+ fetched: list[str] = []
+
+ class _Response:
+ status_code = 200
+ headers = {"content-type": "application/json"}
+ is_redirect = False
+ encoding = "utf-8"
+
+ def __init__(self, url):
+ fetched.append(url)
+ self.content = documents[url].encode()
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *_args):
+ return None
+
+ def iter_bytes(self, *, chunk_size):
+ yield self.content
+
+ def raise_for_status(self):
+ return None
+
+ monkeypatch.setattr(
+ openapi_schema.httpx,
+ "stream",
+ lambda _method, url, **_kwargs: _Response(url),
+ )
+ return fetched
+
+
+def test_bundle_external_refs_fetches_each_document_once(monkeypatch):
+ fetched = _serve_documents(
+ monkeypatch,
+ {
+ "https://api.example/defs.json": '{"a": {"type": "string"}, "b": {"type": "integer"}}'
+ },
+ )
+ bundled = _bundle_external_refs(
+ {
+ "openapi": "3.1.0",
+ "components": {
+ "schemas": {
+ "A": {"$ref": "defs.json#/a"},
+ "B": {"$ref": "defs.json#/b"},
+ }
+ },
+ },
+ "https://api.example/openapi.json",
+ timeout=1,
+ headers={},
+ )
+ assert bundled["components"]["schemas"] == {
+ "A": {"type": "string"},
+ "B": {"type": "integer"},
+ }
+ assert fetched == ["https://api.example/defs.json"]
+
+
+def test_bundle_external_refs_rejects_too_many_documents(monkeypatch):
+ documents = {f"https://api.example/d{i}.json": "{}" for i in range(520)}
+ _serve_documents(monkeypatch, documents)
+ paths = {f"/p{i}": {"$ref": f"d{i}.json"} for i in range(520)}
+ with pytest.raises(ValueError, match="document limit"):
+ _bundle_external_refs(
+ {"openapi": "3.1.0", "paths": paths},
+ "https://api.example/openapi.json",
+ timeout=1,
+ headers={},
+ )
+
+
+def test_bundle_external_refs_rejects_excessive_depth():
+ node: dict = {"type": "string"}
+ for _ in range(130):
+ node = {"nested": node}
+ with pytest.raises(ValueError, match="depth limit"):
+ _bundle_external_refs(
+ {"openapi": "3.1.0", "components": node},
+ "https://api.example/openapi.json",
+ timeout=1,
+ headers={},
+ )
+
+
+def test_bundle_external_refs_rejects_anchor_fragments():
+ with pytest.raises(ValueError, match="anchors are not supported"):
+ _bundle_external_refs(
+ {"openapi": "3.1.0", "paths": {"/x": {"$ref": "x.json#anchor"}}},
+ "https://api.example/openapi.json",
+ timeout=1,
+ headers={},
+ )
+
+
+def test_bundle_external_refs_keeps_circular_references_as_refs(monkeypatch):
+ _serve_documents(
+ monkeypatch,
+ {
+ "https://api.example/a.json": '{"child": {"$ref": "b.json"}}',
+ "https://api.example/b.json": '{"parent": {"$ref": "a.json"}}',
+ },
+ )
+ bundled = _bundle_external_refs(
+ {"openapi": "3.1.0", "components": {"schemas": {"A": {"$ref": "a.json"}}}},
+ "https://api.example/openapi.json",
+ timeout=1,
+ headers={},
+ )
+ assert bundled["components"]["schemas"]["A"] == {
+ "child": {"parent": {"$ref": "https://api.example/a.json#"}}
+ }
+
+
+def test_bundle_external_refs_returns_non_object_targets_as_is(monkeypatch):
+ _serve_documents(
+ monkeypatch, {"https://api.example/values.json": '{"items": ["a", "b"]}'}
+ )
+ bundled = _bundle_external_refs(
+ {"openapi": "3.1.0", "x-values": {"$ref": "values.json#/items"}},
+ "https://api.example/openapi.json",
+ timeout=1,
+ headers={},
+ )
+ assert bundled["x-values"] == ["a", "b"]
+
+
+def test_resolve_json_pointer_walks_lists_and_unescapes_tokens():
+ from openbb_cli.dispatchers.openapi_schema import _resolve_json_pointer
+
+ document = {"a/b": [{"~key": 1}]}
+ assert _resolve_json_pointer(document, "/a~1b/0/~0key") == 1
+
+
+def test_resolve_json_pointer_rejects_relative_and_scalar_paths():
+ from openbb_cli.dispatchers.openapi_schema import _resolve_json_pointer
+
+ with pytest.raises(ValueError, match="Unsupported OpenAPI reference fragment"):
+ _resolve_json_pointer({"a": 1}, "a")
+ with pytest.raises(ValueError, match="Invalid OpenAPI reference fragment"):
+ _resolve_json_pointer({"a": 1}, "/a/b")
+
+
+def test_url_origin_rejects_non_http_urls():
+ from openbb_cli.dispatchers.openapi_schema import _url_origin
+
+ with pytest.raises(ValueError, match="Unsupported OpenAPI reference URL"):
+ _url_origin("file:///etc/passwd")
+
+
+def test_request_body_parameters_unwraps_single_object_anyof_member():
+ body = {
+ "anyOf": [
+ {"type": "object", "properties": {"symbol": {"type": "string"}}},
+ {"type": "null"},
+ ]
+ }
+ assert [p["name"] for p in request_body_parameters(body)] == ["symbol"]
+
+
+def test_fetch_openapi_raises_generic_error_when_response_has_no_parse_error(
+ monkeypatch,
+):
+ """A failing status that ``raise_for_status`` lets through still raises."""
+ from openbb_cli.dispatchers import openapi_schema
+
+ class _Resp:
+ status_code = 404
+ text = "{}"
+ headers = {"content-type": "application/json"}
+
+ def raise_for_status(self):
+ return None
+
+ monkeypatch.setattr(openapi_schema.httpx, "get", lambda *a, **k: _Resp())
+ with pytest.raises(ValueError, match="Could not load an OpenAPI document"):
+ openapi_schema.fetch_openapi("http://h", path="/custom.json")
diff --git a/cli/tests/test_outputs_figure.py b/cli/tests/test_outputs_figure.py
new file mode 100644
index 00000000000..89b7195ee56
--- /dev/null
+++ b/cli/tests/test_outputs_figure.py
@@ -0,0 +1,114 @@
+"""Tests for Plotly figure JSON display."""
+
+import builtins
+import json
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from openbb_cli.outputs import figure as figure_module
+from openbb_cli.outputs.figure import is_plotly_figure, render_figure_html, show_figure
+
+FIGURE = {
+ "data": [{"type": "scatter", "x": [1, 2], "y": [3, 4], "name": ""}],
+ "layout": {"title": {"text": "Term Structure"}, "font": {"color": "white"}},
+ "config": {"displaylogo": False},
+}
+
+
+@pytest.fixture()
+def mock_session():
+ with patch("openbb_cli.outputs.figure.session") as ms:
+ ms.console.print = MagicMock()
+ yield ms
+
+
+class TestIsPlotlyFigure:
+ """Recognizing Plotly figure JSON."""
+
+ def test_figure_dict(self):
+ assert is_plotly_figure(FIGURE)
+
+ @pytest.mark.parametrize(
+ "data",
+ [
+ {"data": [1], "rows": 2},
+ {"data": {"x": 1}, "layout": {}},
+ {"data": [], "layout": "wide"},
+ [FIGURE],
+ None,
+ ],
+ )
+ def test_other_values(self, data):
+ assert not is_plotly_figure(data)
+
+
+class TestRenderFigureHtml:
+ """Rendering figure JSON as a standalone page."""
+
+ def test_embeds_escaped_title_and_figure(self):
+ page = render_figure_html(FIGURE, "/cboe/")
+ assert "/cboe/<options>" in page
+ assert "<\\/script>" in page
+ assert "__FIGURE__" not in page and "__TITLE__" not in page
+ start = page.index("const figure = ") + len("const figure = ")
+ end = page.index(";\n", start)
+ assert json.loads(page[start:end].replace("<\\/", "")) == FIGURE
+
+ def test_inlines_local_plotly(self):
+ from plotly.offline import get_plotlyjs
+
+ assert get_plotlyjs()[:200] in render_figure_html(FIGURE, "t")
+ assert figure_module.PLOTLYJS_CDN not in render_figure_html(FIGURE, "t")
+
+ def test_uses_cdn_without_plotly(self, monkeypatch):
+ real_import = builtins.__import__
+
+ def blocked_import(name, *args, **kwargs):
+ if name == "plotly.offline":
+ raise ImportError(name)
+ return real_import(name, *args, **kwargs)
+
+ monkeypatch.setattr(builtins, "__import__", blocked_import)
+ page = render_figure_html(FIGURE, "t")
+ assert f'' in page
+
+ def test_title_token_in_title_is_not_substituted(self):
+ page = render_figure_html(FIGURE, "__FIGURE__")
+ assert "__FIGURE__" in page
+
+
+class TestShowFigure:
+ """Showing figure JSON in the chart window or the browser."""
+
+ def test_sends_to_backend(self, mock_session):
+ show_figure(FIGURE, "/cboe/options/term_structure")
+ mock_session.backend.send_figure.assert_called_once()
+ fig = mock_session.backend.send_figure.call_args[0][0]
+ assert fig.layout.title.text == "Term Structure"
+ assert len(fig.data) == 1
+ assert (
+ mock_session.backend.send_figure.call_args[1]["command_location"]
+ == "/cboe/options/term_structure"
+ )
+
+ def test_backend_failure_opens_browser(self, mock_session):
+ mock_session.backend.send_figure.side_effect = RuntimeError("no window")
+ with patch("openbb_cli.outputs.figure.webbrowser.open") as open_browser:
+ show_figure(FIGURE, "t")
+ open_browser.assert_called_once()
+ msgs = [str(c) for c in mock_session.console.print.call_args_list]
+ assert any("no window" in m for m in msgs)
+
+ def test_without_backend_opens_browser(self, mock_session, tmp_path):
+ mock_session.backend = None
+ with (
+ patch("openbb_cli.outputs.figure.webbrowser.open") as open_browser,
+ patch(
+ "openbb_cli.outputs.figure.tempfile.NamedTemporaryFile",
+ lambda **kwargs: open(tmp_path / "chart.html", "w", encoding="utf-8"),
+ ),
+ ):
+ show_figure(FIGURE, "t")
+ open_browser.assert_called_once_with((tmp_path / "chart.html").as_uri())
+ assert "Plotly.newPlot" in (tmp_path / "chart.html").read_text()
diff --git a/cli/tests/test_outputs_html.py b/cli/tests/test_outputs_html.py
index 9e22545dbdb..80a26981b8e 100644
--- a/cli/tests/test_outputs_html.py
+++ b/cli/tests/test_outputs_html.py
@@ -165,3 +165,17 @@ class TestHtmlOutputDisplay:
with patch("openbb_cli.outputs.html.webbrowser") as mock_wb:
html_output.display(data=[{"a": 1}, {"a": 2}])
mock_wb.open.assert_called_once()
+
+
+class TestHtmlOutputFigure:
+ """Plotly figure JSON in the HTML adapter."""
+
+ def test_figure_dict_is_shown_as_chart(self, html_output, mock_session):
+ figure = {"data": [{"type": "bar"}], "layout": {}}
+ with (
+ patch("openbb_cli.outputs.html.show_figure") as show,
+ patch("openbb_cli.outputs.html.webbrowser") as mock_wb,
+ ):
+ html_output.display(data=figure, title="t")
+ show.assert_called_once_with(figure, "t")
+ mock_wb.open.assert_not_called()
diff --git a/cli/tests/test_outputs_rich.py b/cli/tests/test_outputs_rich.py
index 1c0087a00f5..7e63ddeb869 100644
--- a/cli/tests/test_outputs_rich.py
+++ b/cli/tests/test_outputs_rich.py
@@ -238,3 +238,17 @@ class TestRichOutputDisplay:
rich_output.display(data=df)
assert captured.get("df") is not None
assert list(captured["df"].columns) == ["0", "1", "2"]
+
+
+class TestRichOutputFigure:
+ """Plotly figure JSON in the rich adapter."""
+
+ def test_figure_dict_is_shown_as_chart(self, rich_output, mock_session):
+ figure = {"data": [{"type": "bar"}], "layout": {}}
+ with (
+ patch("openbb_cli.outputs.rich.show_figure") as show,
+ patch("openbb_cli.outputs.rich.print_rich_table") as mock_prt,
+ ):
+ rich_output.display(data=figure, title="t")
+ show.assert_called_once_with(figure, "t")
+ mock_prt.assert_not_called()
diff --git a/cli/tests/test_outputs_tsv.py b/cli/tests/test_outputs_tsv.py
index 189e6de20ce..c11c976c4a7 100644
--- a/cli/tests/test_outputs_tsv.py
+++ b/cli/tests/test_outputs_tsv.py
@@ -104,3 +104,16 @@ def test_to_dataframe_returns_none_for_unknown():
pass
assert _to_dataframe(X()) is None
+
+
+class TestTsvOutputFigure:
+ """Plotly figure JSON in the TSV adapter."""
+
+ def test_figure_dict_is_written_as_json_line(self, tsv_output, capsys):
+ import json
+
+ figure = {"data": [{"type": "bar"}], "layout": {"title": {"text": "x"}}}
+ tsv_output.display(data=figure)
+ out = capsys.readouterr().out
+ assert out.endswith("\n")
+ assert json.loads(out) == figure
diff --git a/openbb_platform/core/openbb_core/app/static/package_builder/builder.py b/openbb_platform/core/openbb_core/app/static/package_builder/builder.py
index 752c0246486..63a496efbff 100644
--- a/openbb_platform/core/openbb_core/app/static/package_builder/builder.py
+++ b/openbb_platform/core/openbb_core/app/static/package_builder/builder.py
@@ -13,6 +13,7 @@ from json import dumps, load
from pathlib import Path
from typing import (
TYPE_CHECKING,
+ Any,
TypeVar,
)
@@ -228,10 +229,29 @@ class PackageBuilder:
for group, entry_point in zip(og, el.entry_points):
ext_map[group] = [
- f"{e.name}@{getattr(e.dist, 'version', '')}" for e in entry_point
+ f"{e.name}@{self._distribution_version(e.dist)}" for e in entry_point
]
return ext_map
+ @staticmethod
+ def _distribution_version(dist: Any) -> str:
+ """Return a distribution's version, or ``""`` when its metadata is unreadable.
+
+ Parameters
+ ----------
+ dist : Any
+ The distribution that provides an entry point.
+
+ Returns
+ -------
+ str
+ The version string.
+ """
+ try:
+ return getattr(dist, "version", "") or ""
+ except (TypeError, KeyError, OSError):
+ return ""
+
def _save_modules(
self,
modules: str | list[str] | None = None,
diff --git a/openbb_platform/core/tests/app/static/test_package_builder.py b/openbb_platform/core/tests/app/static/test_package_builder.py
index 83eef5c4b0f..4cf8e330fe3 100644
--- a/openbb_platform/core/tests/app/static/test_package_builder.py
+++ b/openbb_platform/core/tests/app/static/test_package_builder.py
@@ -1351,3 +1351,19 @@ def test_normalize_code_annotations_falls_back_on_untokenizable_code():
)
assert _normalize_code_annotations("x: typing.List[int] = (") == "x: list[int] = ("
+
+
+def test_distribution_version_tolerates_unreadable_metadata():
+ from openbb_core.app.static.package_builder.builder import PackageBuilder
+
+ class _BrokenDist:
+ @property
+ def version(self):
+ raise TypeError("'NoneType' object is not subscriptable")
+
+ class _Dist:
+ version = "2.0.0"
+
+ assert PackageBuilder._distribution_version(_BrokenDist()) == ""
+ assert PackageBuilder._distribution_version(_Dist()) == "2.0.0"
+ assert PackageBuilder._distribution_version(None) == ""