Files
OpenBB/cli/tests/test_dispatchers_local.py
Danglewood 9a30186174 [V5] CLI 2.0 - Pluggable Backends, Non-TTY Default, HTTP Dispatcher, Spec Files + Codegen (#7433)
* stash some changes

* add more robust testing

* mypy

* point PR at V5

* introduce spec file

* codespell

* test fix

* fix workflow environment setup

* fix workflow environment setup

* fix workflow environment setup

* add pyyaml to dependencies

* split lint jobs

* fix workflow environment setup

* fix workflow environment setup

* workflow env setup

* workflow env setup

* clean up code comments

* add auth hook entrypoints

* codespell

* add codegen feature

* codespell

* move _unpack into dispatchers for consistency with codegen packages

* surface nested models in the response

* fix missing coverage in CI

* socrata updates

* test fix

* detect plotly output

* add --include and --exclude flags from generate-extension command

* cap test matrix at python 3.14

* no useless comments

* platform controller command description split

* merge URL overloads from path params

* exclude none and unset from model dump

---------

Co-authored-by: deeleeramone <>
Co-authored-by: Copilot <copilot@github.com>
2026-06-01 19:14:38 +03:00

259 lines
8.6 KiB
Python

"""Tests for openbb_cli.dispatchers.local.LocalDispatcher.
Every test resolves commands through the **real** generated static ``obb``
namespace produced by the session-scoped synthetic-extension fixture. Each
``run_in_obb`` call is a fresh subprocess so entry-point caching can't
masquerade as a working dispatcher.
"""
from openbb_cli.dispatchers.local import CommandNotFound, LocalDispatcher
def test_dispatch_resolves_real_command(run_in_obb):
"""The dispatcher walks ``obb.cli_test.echo`` and returns its OBBject."""
result = run_in_obb("""
import asyncio
from openbb_cli.dispatchers.local import LocalDispatcher
from openbb_cli.dispatchers.protocol import Request
d = LocalDispatcher()
resp = asyncio.run(
d.dispatch(Request(id="r1", command="cli_test.echo", params={"value": "hi"}))
)
RESULT = resp.model_dump()
""")
assert result["ok"] is True
assert result["id"] == "r1"
assert result["result"]["results"] == {"echo": "hi"}
def test_dispatch_async_command(run_in_obb):
"""Async commands are awaited; results round-trip through OBBject."""
result = run_in_obb("""
import asyncio
from openbb_cli.dispatchers.local import LocalDispatcher
from openbb_cli.dispatchers.protocol import Request
d = LocalDispatcher()
resp = asyncio.run(
d.dispatch(Request(command="cli_test.quote", params={"symbol": "TSLA"}))
)
RESULT = resp.model_dump()
""")
assert result["ok"] is True
assert result["result"]["results"] == {"symbol": "TSLA", "async": True}
def test_dispatch_missing_command(run_in_obb):
"""Unknown command paths surface as CommandNotFound, not raised."""
result = run_in_obb("""
import asyncio
from openbb_cli.dispatchers.local import LocalDispatcher
from openbb_cli.dispatchers.protocol import Request
d = LocalDispatcher()
resp = asyncio.run(d.dispatch(Request(command="not.a.real.thing")))
RESULT = resp.model_dump()
""")
assert result["ok"] is False
assert result["error"]["type"] == "CommandNotFound"
def test_dispatch_missing_required_param(run_in_obb):
"""Missing required param surfaces as a structured error.
``cli_test.quote`` declares ``symbol: str`` as required. openbb-core's
router wrapping rejects the call when it isn't supplied; the dispatcher
reports the failure as ``ok=False`` with a populated ``error`` field.
"""
result = run_in_obb("""
import asyncio
from openbb_cli.dispatchers.local import LocalDispatcher
from openbb_cli.dispatchers.protocol import Request
d = LocalDispatcher()
resp = asyncio.run(
d.dispatch(Request(command="cli_test.quote", params={}))
)
RESULT = resp.model_dump()
""")
assert result["ok"] is False
assert result["error"] is not None
assert result["error"]["type"]
def test_dispatch_runtime_error_isolated(run_in_obb):
"""A command that raises produces an error Response, not propagation.
openbb-core wraps user-raised exceptions in ``OpenBBError`` at the
command-runner boundary, so that's the type the dispatcher reports.
"""
result = run_in_obb("""
import asyncio
from openbb_cli.dispatchers.local import LocalDispatcher
from openbb_cli.dispatchers.protocol import Request
d = LocalDispatcher()
resp = asyncio.run(d.dispatch(Request(command="cli_test.bomb")))
RESULT = resp.model_dump()
""")
assert result["ok"] is False
assert result["error"]["type"] == "OpenBBError"
assert "boom" in result["error"]["message"]
def test_dispatch_empty_segment_rejected(run_in_obb):
result = run_in_obb("""
import asyncio
from openbb_cli.dispatchers.local import LocalDispatcher
from openbb_cli.dispatchers.protocol import Request
d = LocalDispatcher()
resp = asyncio.run(d.dispatch(Request(command="cli_test..echo")))
RESULT = resp.model_dump()
""")
assert result["ok"] is False
assert result["error"]["type"] == "CommandNotFound"
def test_dispatch_list_results_round_trip(run_in_obb):
"""List-typed OBBject results survive serialization to dict."""
result = run_in_obb("""
import asyncio
from openbb_cli.dispatchers.local import LocalDispatcher
from openbb_cli.dispatchers.protocol import Request
d = LocalDispatcher()
resp = asyncio.run(
d.dispatch(Request(command="cli_test.rows", params={"n": 3}))
)
RESULT = resp.model_dump()
""")
assert result["ok"] is True
assert result["result"]["results"] == [
{"i": 0, "v": 0},
{"i": 1, "v": 2},
{"i": 2, "v": 4},
]
def test_aclose_is_noop(run_in_obb):
result = run_in_obb("""
import asyncio
from openbb_cli.dispatchers.local import LocalDispatcher
d = LocalDispatcher()
asyncio.run(d.aclose())
RESULT = {"closed": True}
""")
assert result == {"closed": True}
def test_serialize_passes_through_primitives():
assert LocalDispatcher._serialize(None) is None
assert LocalDispatcher._serialize(42) == 42
assert LocalDispatcher._serialize("x") == "x"
assert LocalDispatcher._serialize([1, 2, 3]) == [1, 2, 3]
def test_serialize_uses_model_dump():
class M:
def model_dump(
self, *, exclude_unset: bool = False, exclude_none: bool = False
) -> dict:
return {
"v": 1,
"exclude_unset": exclude_unset,
"exclude_none": exclude_none,
}
assert LocalDispatcher._serialize(M()) == {
"v": 1,
"exclude_unset": True,
"exclude_none": True,
}
def test_serialize_uses_to_dict_for_dataframe_like():
"""Hits the ``to_dict(orient='records')`` branch in ``_serialize``."""
class D:
def to_dict(self, orient: str):
assert orient == "records"
return [{"a": 1}, {"a": 2}]
assert LocalDispatcher._serialize(D()) == [{"a": 1}, {"a": 2}]
def test_resolve_walks_attribute_path():
from types import SimpleNamespace
root = SimpleNamespace(a=SimpleNamespace(b=SimpleNamespace(c="leaf")))
assert LocalDispatcher._resolve(root, "a.b.c") == "leaf"
def test_resolve_empty_segment_raises():
import pytest
with pytest.raises(CommandNotFound):
LocalDispatcher._resolve(object(), "a..b")
def test_resolve_unknown_attribute_raises():
import pytest
with pytest.raises(CommandNotFound):
LocalDispatcher._resolve(object(), "missing")
def test_command_not_found_subclasses_keyerror():
assert issubclass(CommandNotFound, KeyError)
def test_dispatch_runs_sync_command_in_thread_pool():
"""Sync (non-async) callables go through ``asyncio.to_thread``."""
import asyncio
import sys
from types import SimpleNamespace
from unittest.mock import patch as _patch
from openbb_cli.dispatchers.protocol import Request
def sync_echo(value: str) -> dict:
return {"echo": value, "ran_in": "main_or_thread"}
fake = SimpleNamespace(cli_test=SimpleNamespace(echo=sync_echo))
fake_module = SimpleNamespace(obb=fake)
with _patch.dict(sys.modules, {"openbb": fake_module}):
d = LocalDispatcher()
resp = asyncio.run(
d.dispatch(Request(command="cli_test.echo", params={"value": "x"}))
)
assert resp.ok is True
assert resp.result == {"echo": "x", "ran_in": "main_or_thread"}
def test_dispatch_runs_async_command_in_loop():
"""Async (coroutine) callables are awaited directly."""
import asyncio
import sys
from types import SimpleNamespace
from unittest.mock import patch as _patch
from openbb_cli.dispatchers.protocol import Request
async def async_echo(value: str) -> dict:
await asyncio.sleep(0)
return {"echo": value, "ran_in": "loop"}
fake = SimpleNamespace(cli_test=SimpleNamespace(echo=async_echo))
fake_module = SimpleNamespace(obb=fake)
with _patch.dict(sys.modules, {"openbb": fake_module}):
d = LocalDispatcher()
resp = asyncio.run(
d.dispatch(Request(command="cli_test.echo", params={"value": "y"}))
)
assert resp.ok is True
assert resp.result == {"echo": "y", "ran_in": "loop"}
def test_run_in_obb_propagates_subprocess_failure(run_in_obb):
"""The helper raises AssertionError if the subprocess exits non-zero."""
import pytest
with pytest.raises(AssertionError) as exc_info:
run_in_obb("RESULT = 1/0")
assert "ZeroDivisionError" in str(exc_info.value) or "rc=" in str(exc_info.value)