mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-10-27 12:40:35 +00:00
fix cli renderer helper with figure object JSON
This commit is contained in:
5
cli/README.md
vendored
5
cli/README.md
vendored
@@ -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)}"})
|
||||
```
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
33
cli/openbb_cli/assets/figure.html
vendored
Normal file
33
cli/openbb_cli/assets/figure.html
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>__TITLE__</title>
|
||||
__PLOTLYJS__
|
||||
<style>
|
||||
html, body, #chart {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="chart"></div>
|
||||
<script>
|
||||
const figure = __FIGURE__;
|
||||
const probe = document.createElement("span");
|
||||
probe.style.color = (figure.layout.font || {}).color || "black";
|
||||
document.body.appendChild(probe);
|
||||
const [r, g, b] = getComputedStyle(probe).color.match(/\d+/g).map(Number);
|
||||
probe.remove();
|
||||
document.body.style.background =
|
||||
0.2126 * r + 0.7152 * g + 0.0722 * b > 128 ? "#151518" : "#ffffff";
|
||||
Plotly.newPlot(
|
||||
"chart",
|
||||
figure.data,
|
||||
figure.layout,
|
||||
Object.assign({ responsive: true }, figure.config || {})
|
||||
);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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):
|
||||
|
||||
@@ -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]:
|
||||
|
||||
113
cli/openbb_cli/outputs/figure.py
Normal file
113
cli/openbb_cli/outputs/figure.py
Normal file
@@ -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 ``<script>`` with the local plotly.js bundle, or a CDN reference.
|
||||
"""
|
||||
try:
|
||||
from plotly.offline import get_plotlyjs
|
||||
except ImportError:
|
||||
return f'<script src="{PLOTLYJS_CDN}"></script>'
|
||||
return f"<script>{get_plotlyjs()}</script>"
|
||||
|
||||
|
||||
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]")
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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="")'
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
114
cli/tests/test_outputs_figure.py
Normal file
114
cli/tests/test_outputs_figure.py
Normal file
@@ -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": "</script>"}],
|
||||
"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/<options>")
|
||||
assert "<title>/cboe/<options></title>" 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'<script src="{figure_module.PLOTLYJS_CDN}"></script>' in page
|
||||
|
||||
def test_title_token_in_title_is_not_substituted(self):
|
||||
page = render_figure_html(FIGURE, "__FIGURE__")
|
||||
assert "<title>__FIGURE__</title>" 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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) == ""
|
||||
|
||||
Reference in New Issue
Block a user