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(" 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("<\\/", "' 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) == ""