"""Tests for openbb_cli.dispatchers.http — HttpDispatcher against openbb-platform-api.""" import json import httpx import pytest from openbb_cli.dispatchers.http import HttpDispatcher from openbb_cli.dispatchers.protocol import Request def _make_dispatcher(handler) -> HttpDispatcher: transport = httpx.MockTransport(handler) client = httpx.AsyncClient(transport=transport, base_url="http://test") return HttpDispatcher("http://test", client=client) @pytest.mark.asyncio async def test_dispatch_success_translates_dotted_path(): captured = {} def handler(request: httpx.Request) -> httpx.Response: captured["url"] = str(request.url) captured["body"] = json.loads(request.content) if request.content else {} return httpx.Response(200, json={"echo": captured["body"]}) d = _make_dispatcher(handler) try: resp = await d.dispatch( Request( id="x", command="equity.price.historical", params={"symbol": "AAPL"} ) ) finally: await d.aclose() assert resp.ok is True assert resp.id == "x" # The single-key ``echo`` wrapper gets stripped by the response # unwrap, matching how an installed extension surfaces the same row. assert resp.result == {"symbol": "AAPL"} assert "/api/v1/equity/price/historical" in captured["url"] @pytest.mark.asyncio async def test_dispatch_4xx_yields_http_error(): def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(404, json={"detail": "not found"}) d = _make_dispatcher(handler) try: resp = await d.dispatch(Request(command="nope")) finally: await d.aclose() assert resp.ok is False assert resp.error is not None assert resp.error.type == "HTTP404" @pytest.mark.asyncio async def test_dispatch_5xx_with_text_body(): def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(500, text="internal explosion") d = _make_dispatcher(handler) try: resp = await d.dispatch(Request(command="equity.q")) finally: await d.aclose() assert resp.ok is False assert resp.error is not None assert resp.error.type == "HTTP500" @pytest.mark.asyncio async def test_dispatch_request_error(): def handler(request: httpx.Request) -> httpx.Response: raise httpx.ConnectError("dns failed") d = _make_dispatcher(handler) try: resp = await d.dispatch(Request(command="x")) finally: await d.aclose() assert resp.ok is False assert resp.error is not None assert resp.error.type == "ConnectError" @pytest.mark.asyncio async def test_dispatch_unexpected_exception_isolated(): def handler(request: httpx.Request) -> httpx.Response: raise RuntimeError("boom") d = _make_dispatcher(handler) try: resp = await d.dispatch(Request(command="x")) finally: await d.aclose() assert resp.ok is False assert resp.error is not None assert resp.error.type in {"RuntimeError", "TransportError"} @pytest.mark.asyncio async def test_aclose_owns_client_when_constructed_internally(): d = HttpDispatcher("http://x") assert d._owns_client is True await d.aclose() @pytest.mark.asyncio async def test_aclose_does_not_close_external_client(): transport = httpx.MockTransport(lambda r: httpx.Response(200, json={})) client = httpx.AsyncClient(transport=transport) d = HttpDispatcher("http://x", client=client) await d.aclose() response = await client.get("http://x/") assert response.status_code == 200 await client.aclose() def test_url_for_strips_separators(): d = HttpDispatcher("http://srv/") assert d._url_for("equity.price") == "http://srv/api/v1/equity/price" def test_custom_api_prefix(): d = HttpDispatcher("http://srv", api_prefix="custom/v2") assert d._url_for("ping") == "http://srv/custom/v2/ping" @pytest.mark.asyncio async def test_dispatch_unwraps_single_array_envelope(): """``{"refRates": [...]}`` -> the rows list, matching codegen behavior.""" def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, json={ "refRates": [ {"effectiveDate": "2026-04-30", "type": "TGCR"}, {"effectiveDate": "2026-04-29", "type": "TGCR"}, ] }, ) d = _make_dispatcher(handler) try: resp = await d.dispatch(Request(command="rates.last")) finally: await d.aclose() assert resp.ok is True assert resp.result == [ {"effectiveDate": "2026-04-30", "type": "TGCR"}, {"effectiveDate": "2026-04-29", "type": "TGCR"}, ] @pytest.mark.asyncio async def test_dispatch_splits_metadata_from_rows(): """Sibling API metadata (NY-Fed-style ``asOfDate`` next to a row array) lands at its own key under ``extra`` of an OBBject — NOT folded into ``results_metadata`` (which is per-column data) and NOT folded into ``metadata`` (which is execution telemetry). Three distinct concepts, three distinct keys. """ from openbb_core.app.model.obbject import OBBject def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, json={"asOfDate": "2026-04-30", "operations": [{"id": 1}, {"id": 2}]}, ) d = _make_dispatcher(handler) try: resp = await d.dispatch(Request(command="x")) finally: await d.aclose() assert resp.ok is True assert isinstance(resp.result, OBBject) assert resp.result.results == [{"id": 1}, {"id": 2}] # ``asOfDate`` lands directly under ``extra`` — its own key, not # buried inside ``results_metadata`` (which is per-column) and not # inside ``metadata`` (which is execution info). assert resp.result.extra["asOfDate"] == "2026-04-30" assert "results_metadata" not in resp.result.extra # ``metadata`` is NOT a top-level OBBject property — it lives under # ``extra``. The dump confirms the shape. dumped = resp.result.model_dump() assert "metadata" not in dumped # never at top level assert "results_metadata" not in dumped # ditto @pytest.mark.asyncio async def test_dispatch_unwraps_array_of_scalars_into_value_field_then_flattens(): """Single-key envelope around a string array -> a flat list of strings.""" def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json={"asOfDates": ["2026-04-30", "2026-04-23"]}) d = _make_dispatcher(handler) try: resp = await d.dispatch(Request(command="x")) finally: await d.aclose() # Each scalar gets wrapped as ``{value: x}`` by ``unpack_response``; # ``_shape_result`` then unwraps the synthetic ``value`` key for clean # rendering. assert resp.result == ["2026-04-30", "2026-04-23"] @pytest.mark.asyncio async def test_dispatch_passes_text_response_through_unchanged(): """Non-JSON responses (XML, CSV, plain text) bypass the unwrap entirely.""" def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, text="raw", headers={"content-type": "application/xml"} ) d = _make_dispatcher(handler) try: resp = await d.dispatch(Request(command="x")) finally: await d.aclose() assert resp.result == "raw" @pytest.mark.asyncio async def test_dispatch_returns_payload_unchanged_when_unwrap_yields_nothing(): """Empty list response: ``unpack_response`` produces ``([], {})``; the dispatcher leaves the original payload untouched rather than collapsing to ``None``.""" def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json=[]) d = _make_dispatcher(handler) try: resp = await d.dispatch(Request(command="x")) finally: await d.aclose() assert resp.result == [] @pytest.mark.asyncio async def test_dispatch_uses_get_when_command_methods_says_so(): captured = {} def handler(request: httpx.Request) -> httpx.Response: captured["method"] = request.method captured["url"] = str(request.url) return httpx.Response(200, json={"ok": True}) transport = httpx.MockTransport(handler) client = httpx.AsyncClient(transport=transport, base_url="http://t") d = HttpDispatcher( "http://t", client=client, command_methods={"equity.quote": "get"}, ) try: await d.dispatch(Request(command="equity.quote", params={"symbol": "AAPL"})) finally: await d.aclose() assert captured["method"] == "GET" assert "symbol=AAPL" in captured["url"] @pytest.mark.asyncio async def test_dispatch_falls_back_to_post_without_method_map(): captured = {} def handler(request: httpx.Request) -> httpx.Response: captured["method"] = request.method return httpx.Response(200, json={}) transport = httpx.MockTransport(handler) client = httpx.AsyncClient(transport=transport, base_url="http://t") d = HttpDispatcher("http://t", client=client) try: await d.dispatch(Request(command="equity.quote", params={"symbol": "X"})) finally: await d.aclose() assert captured["method"] == "POST" @pytest.mark.asyncio async def test_dispatch_explicit_method_overrides_map(): captured = {} def handler(request: httpx.Request) -> httpx.Response: captured["method"] = request.method return httpx.Response(200, json={}) transport = httpx.MockTransport(handler) client = httpx.AsyncClient(transport=transport, base_url="http://t") d = HttpDispatcher( "http://t", client=client, command_methods={"x": "post"}, ) try: await d.dispatch(Request(command="x"), method="get") finally: await d.aclose() assert captured["method"] == "GET" def test_http_dispatcher_from_spec_extracts_methods(): from openbb_cli.dispatchers.http import http_dispatcher_from_spec spec_doc = { "base_url": "http://h", "api_prefix": "/api/v1", "commands": { "equity.quote": {"method": "get"}, "scratch.rebuild": {"method": "post"}, }, } d = http_dispatcher_from_spec(spec_doc) assert d._command_methods == {"equity.quote": "get", "scratch.rebuild": "post"} assert d._base_url == "http://h" def test_http_dispatcher_from_server_fetches_and_maps(monkeypatch): """``http_dispatcher_from_server`` synthesizes a methods map from openapi.json.""" from openbb_cli.dispatchers import http as http_mod fake_openapi = { "paths": { "/api/v1/x/y": {"get": {}}, "/api/v1/foo/bar": {"post": {}}, "/api/v1/skip": {"delete": {}}, } } def fake_fetch( base_url, *, timeout=10.0, path=None, headers=None, query_params=None ): return fake_openapi monkeypatch.setattr( "openbb_cli.dispatchers.openapi_schema.fetch_openapi", fake_fetch ) d = http_mod.http_dispatcher_from_server("http://h") assert d._command_methods == {"x.y": "get", "foo.bar": "post"} @pytest.mark.asyncio async def test_dispatch_sends_constructor_headers(): """Headers passed at construction time go on every request.""" captured = {} def handler(request: httpx.Request) -> httpx.Response: captured["headers"] = dict(request.headers) return httpx.Response(200, json={"ok": True}) transport = httpx.MockTransport(handler) dispatcher = HttpDispatcher( "http://t", headers={"Authorization": "Bearer xyz", "X-Tenant": "acme"}, client=httpx.AsyncClient( transport=transport, base_url="http://t", headers={"Authorization": "Bearer xyz", "X-Tenant": "acme"}, ), command_methods={"x": "get"}, ) try: await dispatcher.dispatch(Request(command="x")) finally: await dispatcher.aclose() assert captured["headers"]["authorization"] == "Bearer xyz" assert captured["headers"]["x-tenant"] == "acme" def test_http_dispatcher_owns_client_when_headers_passed(): """No explicit client → dispatcher remembers headers + builds a fresh client per ``dispatch()`` call. We assert the bookkeeping; the actual header round-trip is covered by the ``handler``-based dispatch tests above which observe headers on the wire. """ d = HttpDispatcher( "http://h", headers={"X-Foo": "bar"}, ) assert d._headers == {"X-Foo": "bar"} assert d._owns_client is True assert d._client is None def test_http_dispatcher_from_spec_passes_headers(): """``http_dispatcher_from_spec`` forwards headers to the dispatcher.""" from openbb_cli.dispatchers.http import http_dispatcher_from_spec spec_doc = { "base_url": "http://h", "api_prefix": "/api/v1", "commands": {"foo": {"method": "get", "url_path": "/api/v1/foo"}}, } d = http_dispatcher_from_spec(spec_doc, headers={"X-API-Key": "k"}) assert d._headers == {"X-API-Key": "k"} @pytest.mark.asyncio async def test_dispatch_substitutes_path_params_into_url_template(): """``{format}``-style placeholders are substituted from the request params.""" captured = {} def handler(request: httpx.Request) -> httpx.Response: captured["url"] = str(request.url) return httpx.Response(200, json={"ok": True}) transport = httpx.MockTransport(handler) client = httpx.AsyncClient(transport=transport, base_url="http://t") d = HttpDispatcher( "http://t", client=client, command_methods={"fxs.list.counterparties": "get"}, command_url_paths={ "fxs.list.counterparties": "/api/fxs/list/counterparties.{format}" }, ) try: await d.dispatch( Request( command="fxs.list.counterparties", params={"format": "json", "extra": "value"}, ) ) finally: await d.aclose() assert "/api/fxs/list/counterparties.json" in captured["url"] assert "format=" not in captured["url"] assert "extra=value" in captured["url"] @pytest.mark.asyncio async def test_dispatch_keeps_placeholder_when_param_missing(): """A missing path param leaves the placeholder so the server returns a clean error.""" captured = {} def handler(request: httpx.Request) -> httpx.Response: captured["url"] = str(request.url) return httpx.Response(200, json={}) transport = httpx.MockTransport(handler) client = httpx.AsyncClient(transport=transport, base_url="http://t") d = HttpDispatcher( "http://t", client=client, command_methods={"x": "get"}, command_url_paths={"x": "/api/x/{missing}"}, ) try: await d.dispatch(Request(command="x", params={})) finally: await d.aclose() assert "%7Bmissing%7D" in captured["url"] or "{missing}" in captured["url"] @pytest.mark.asyncio async def test_dispatch_picks_longest_satisfied_url_template(): """When a command has multiple URL templates, pick the longest one whose placeholders are filled.""" captured = {} def handler(request: httpx.Request) -> httpx.Response: captured["url"] = str(request.url) return httpx.Response(200, json={}) transport = httpx.MockTransport(handler) client = httpx.AsyncClient(transport=transport, base_url="http://t") d = HttpDispatcher( "http://t", client=client, command_methods={"x": "get"}, command_url_paths={ "x": [ "/api/ratings/historical/{country}", "/api/ratings/historical/{country}/{d1}/{d2}", ] }, ) try: await d.dispatch( Request( command="x", params={"country": "USA", "d1": "2024-01-01", "d2": "2024-12-31"}, ) ) finally: await d.aclose() assert "/api/ratings/historical/USA/2024-01-01/2024-12-31" in captured["url"] @pytest.mark.asyncio async def test_dispatch_falls_back_to_shorter_template_when_extras_missing(): """When the longer template is missing extra path params, fall back to the shorter.""" captured = {} def handler(request: httpx.Request) -> httpx.Response: captured["url"] = str(request.url) return httpx.Response(200, json={}) transport = httpx.MockTransport(handler) client = httpx.AsyncClient(transport=transport, base_url="http://t") d = HttpDispatcher( "http://t", client=client, command_methods={"x": "get"}, command_url_paths={ "x": [ "/api/ratings/historical/{country}", "/api/ratings/historical/{country}/{d1}/{d2}", ] }, ) try: await d.dispatch(Request(command="x", params={"country": "USA"})) finally: await d.aclose() assert "/api/ratings/historical/USA" in captured["url"] assert "{d1}" not in captured["url"] assert "%7Bd1%7D" not in captured["url"] @pytest.mark.asyncio async def test_dispatch_falls_back_to_no_placeholder_template_when_path_params_missing(): """When no path params are supplied, a placeholder-free template still wins.""" captured = {} def handler(request: httpx.Request) -> httpx.Response: captured["url"] = str(request.url) return httpx.Response(200, json={}) transport = httpx.MockTransport(handler) client = httpx.AsyncClient(transport=transport, base_url="http://t") d = HttpDispatcher( "http://t", client=client, command_methods={"x": "get"}, command_url_paths={"x": ["/api/ratings", "/api/ratings/{country}"]}, ) try: await d.dispatch(Request(command="x", params={})) finally: await d.aclose() assert captured["url"].endswith("/api/ratings") def test_http_dispatcher_from_spec_uses_url_templates_when_present(): """The spec-doc factory routes ``url_templates`` through to the dispatcher.""" from openbb_cli.dispatchers.http import http_dispatcher_from_spec spec_doc = { "base_url": "http://h", "api_prefix": "/api/v1", "commands": { "x": { "method": "get", "url_path": "/api/x", "url_templates": ["/api/x", "/api/x/{id}"], } }, } d = http_dispatcher_from_spec(spec_doc) assert d._command_url_paths["x"] == ["/api/x", "/api/x/{id}"] @pytest.mark.asyncio async def test_dispatch_invokes_sync_auth_hook_and_merges_headers(): """Sync hook returning headers/query gets merged into the request.""" from openbb_cli.auth import AuthContext, AuthDecision captured: dict[str, object] = {} seen: list[AuthContext] = [] def hook(ctx): seen.append(ctx) return AuthDecision(headers={"X-User": "alice"}, query_params={"trace": "1"}) def handler(request: httpx.Request) -> httpx.Response: captured["headers"] = dict(request.headers) captured["url"] = str(request.url) return httpx.Response(200, json={}) transport = httpx.MockTransport(handler) client = httpx.AsyncClient(transport=transport, base_url="http://test") d = HttpDispatcher( "http://test", client=client, command_methods={"foo": "get"}, auth_hook=hook, namespace="ns1", ) try: resp = await d.dispatch(Request(command="foo", params={"a": 1})) finally: await d.aclose() assert resp.ok assert captured["headers"]["x-user"] == "alice" assert "trace=1" in captured["url"] assert seen[0].namespace == "ns1" assert seen[0].command == "foo" assert seen[0].params == {"a": 1} @pytest.mark.asyncio async def test_dispatch_supports_async_auth_hook(): """Awaited coroutine hooks work the same as sync ones.""" from openbb_cli.auth import AuthDecision async def hook(ctx): return AuthDecision(headers={"X-Async": "yes"}) captured: dict[str, dict[str, str]] = {} def handler(request: httpx.Request) -> httpx.Response: captured["h"] = dict(request.headers) return httpx.Response(200, json={}) transport = httpx.MockTransport(handler) client = httpx.AsyncClient(transport=transport, base_url="http://test") d = HttpDispatcher( "http://test", client=client, command_methods={"foo": "get"}, auth_hook=hook, ) try: await d.dispatch(Request(command="foo")) finally: await d.aclose() assert captured["h"]["x-async"] == "yes" @pytest.mark.asyncio async def test_dispatch_auth_hook_deny_short_circuits(): """``allow=False`` produces an AccessDenied error and skips the network call.""" from openbb_cli.auth import AuthDecision network_calls: list[str] = [] def hook(ctx): return AuthDecision(allow=False, deny_reason="role too low") def handler(request: httpx.Request) -> httpx.Response: network_calls.append(str(request.url)) return httpx.Response(200, json={}) transport = httpx.MockTransport(handler) client = httpx.AsyncClient(transport=transport, base_url="http://test") d = HttpDispatcher("http://test", client=client, auth_hook=hook) try: resp = await d.dispatch(Request(command="x")) finally: await d.aclose() assert not resp.ok assert resp.error.type == "AccessDenied" assert "role too low" in resp.error.message assert network_calls == [] @pytest.mark.asyncio async def test_dispatch_auth_hook_raises_becomes_deny(): """A raising hook becomes an ``AccessDenied`` response, not a crash.""" def hook(ctx): raise RuntimeError("vault unreachable") transport = httpx.MockTransport(lambda r: httpx.Response(500)) client = httpx.AsyncClient(transport=transport, base_url="http://test") d = HttpDispatcher("http://test", client=client, auth_hook=hook) try: resp = await d.dispatch(Request(command="x")) finally: await d.aclose() assert not resp.ok assert resp.error.type == "AccessDenied" assert "vault unreachable" in resp.error.message @pytest.mark.asyncio async def test_dispatch_auth_hook_wrong_return_type_denies(): """A hook returning the wrong shape is treated as a deny, not silent ok.""" def hook(ctx): return {"headers": {"x": "1"}} # not an AuthDecision transport = httpx.MockTransport(lambda r: httpx.Response(200)) client = httpx.AsyncClient(transport=transport, base_url="http://test") d = HttpDispatcher("http://test", client=client, auth_hook=hook) try: resp = await d.dispatch(Request(command="x")) finally: await d.aclose() assert not resp.ok assert resp.error.type == "AccessDenied" assert "expected AuthDecision" in resp.error.message @pytest.mark.asyncio async def test_list_commands_filters_out_denied_entries(): """``__commands__`` runs the auth hook for each command; deny → drop.""" from openbb_cli.auth import AuthDecision def hook(ctx): # Only allow ``allowed.cmd`` if ctx.command == "allowed.cmd": return AuthDecision() return AuthDecision(allow=False, deny_reason="rbac") d = HttpDispatcher( "http://test", spec_doc={ "commands": { "allowed.cmd": {"description": "Visible."}, "denied.cmd": {"description": "Hidden."}, } }, auth_hook=hook, ) resp = await d.dispatch(Request(command="__commands__")) assert resp.ok names = [r["name"] for r in resp.result] assert names == ["allowed.cmd"] @pytest.mark.asyncio async def test_describe_denied_returns_access_denied(): """``__schema__`` for a denied command returns ``AccessDenied``.""" from openbb_cli.auth import AuthDecision d = HttpDispatcher( "http://test", spec_doc={ "commands": { "secret.thing": { "description": "Hidden.", "parameters": [], "providers": [], "request_body_schema": None, "response_schema": None, } } }, auth_hook=lambda ctx: AuthDecision(allow=False, deny_reason="rbac"), ) resp = await d.dispatch( Request(command="__schema__", params={"name": "secret.thing"}) ) assert not resp.ok assert resp.error.type == "AccessDenied" assert "rbac" in resp.error.message @pytest.mark.asyncio async def test_describe_returns_slim_shape_for_non_multi_provider(): """Single-provider commands get the flat ``{name, parameters, output_schema}``.""" d = HttpDispatcher( "http://test", spec_doc={ "commands": { "foo": { "description": "F", "parameters": [ { "name": "x", "in": "query", "type": "string", "is_list": False, "required": True, "default": None, "choices": [], "help": None, "providers": [], } ], "providers": [], "request_body_schema": None, "response_schema": {"type": "object"}, } } }, ) resp = await d._describe_command( Request(command="__schema__", params={"name": "foo"}) ) assert resp.ok assert set(resp.result) == {"name", "parameters", "output_schema"} p = resp.result["parameters"][0] # Empty/falsy fields stripped assert "is_list" not in p assert "default" not in p assert "choices" not in p assert "help" not in p assert p["required"] is True @pytest.mark.asyncio async def test_describe_groups_by_provider_when_present(): """Multi-provider commands return ``{name, providers: {ns: {...}, ...}}``.""" d = HttpDispatcher( "http://test", spec_doc={ "commands": { "q": { "description": "Q", "parameters": [ { "name": "symbol", "in": "query", "type": "string", "required": True, "providers": [], }, { "name": "use_cache", "in": "query", "type": "boolean", "providers": ["cboe"], }, { "name": "source", "in": "query", "type": "string", "providers": ["intrinio"], }, ], "providers": ["cboe", "intrinio"], "request_body_schema": None, "response_schema": None, } } }, ) resp = await d._describe_command( Request(command="__schema__", params={"name": "q"}) ) assert resp.ok by = resp.result["providers"] assert set(by) == {"cboe", "intrinio"} cboe_params = [p["name"] for p in by["cboe"]["parameters"]] intrinio_params = [p["name"] for p in by["intrinio"]["parameters"]] assert cboe_params == ["symbol", "use_cache"] assert intrinio_params == ["symbol", "source"] @pytest.mark.asyncio async def test_describe_with_provider_suffix_returns_single_slice(): """Passing ``provider`` narrows the output to that provider's slice.""" d = HttpDispatcher( "http://test", spec_doc={ "commands": { "q": { "description": "Q", "parameters": [ { "name": "symbol", "in": "query", "type": "string", "required": True, "providers": [], }, { "name": "source", "in": "query", "type": "string", "providers": ["intrinio"], }, ], "providers": ["cboe", "intrinio"], "request_body_schema": None, "response_schema": None, } } }, ) resp = await d._describe_command( Request(command="__schema__", params={"name": "q", "provider": "intrinio"}) ) assert resp.ok assert resp.result["provider"] == "intrinio" assert set(resp.result) == {"name", "provider", "parameters", "output_schema"} names = [p["name"] for p in resp.result["parameters"]] assert names == ["symbol", "source"] @pytest.mark.asyncio async def test_describe_unknown_provider_returns_error(): d = HttpDispatcher( "http://test", spec_doc={ "commands": { "q": { "description": "Q", "parameters": [], "providers": ["cboe"], "request_body_schema": None, "response_schema": None, } } }, ) resp = await d._describe_command( Request(command="__schema__", params={"name": "q", "provider": "bogus"}) ) assert not resp.ok assert resp.error.type == "UnknownProvider" assert "Available: cboe" in resp.error.message def test_help_for_provider_keeps_only_relevant_sections(): """Per-provider help filtering: drops ``(provider: X)`` blocks for other providers.""" from openbb_cli.dispatchers.http import _help_for_provider text = ( "Shared header for everyone.;\n " "FMP says hi. (provider: fmp);\n " "Intrinio says hi. (provider: intrinio)" ) assert _help_for_provider(text, "fmp") == ( "Shared header for everyone.\nFMP says hi." ) assert _help_for_provider(text, "intrinio") == ( "Shared header for everyone.\nIntrinio says hi." ) def test_help_for_provider_returns_none_when_nothing_applies(): from openbb_cli.dispatchers.http import _help_for_provider text = "Only for cboe. (provider: cboe)" assert _help_for_provider(text, "fmp") is None def test_help_for_provider_passthrough_for_empty(): from openbb_cli.dispatchers.http import _help_for_provider assert _help_for_provider(None, "fmp") is None assert _help_for_provider("", "fmp") == "" def test_provider_output_schema_picks_matching_variant(): """``IntrinioEquityQuoteData`` matches provider ``intrinio`` by case-insensitive prefix.""" from openbb_cli.dispatchers.http import _provider_output_schema schema = { "properties": { "results": { "anyOf": [ { "items": { "oneOf": [ {"title": "IntrinioEquityQuoteData", "type": "object"}, {"title": "FMPEquityQuoteData", "type": "object"}, ] }, "type": "array", }, {"type": "null"}, ] } } } intrinio = _provider_output_schema(schema, "intrinio") assert intrinio is not None assert intrinio["title"] == "IntrinioEquityQuoteData" def test_provider_output_schema_handles_non_array_results(): """Single-record results with a top-level ``oneOf`` work the same way.""" from openbb_cli.dispatchers.http import _provider_output_schema schema = { "properties": { "results": { "oneOf": [ {"title": "IntrinioFoo", "type": "object"}, {"title": "FMPFoo", "type": "object"}, ] } } } out = _provider_output_schema(schema, "fmp") assert out is not None assert out["title"] == "FMPFoo" def test_provider_output_schema_returns_none_for_invalid_input(): from openbb_cli.dispatchers.http import _provider_output_schema assert _provider_output_schema(None, "fmp") is None assert _provider_output_schema({}, "fmp") is None def test_http_dispatcher_from_server_passes_headers(monkeypatch): """``http_dispatcher_from_server`` forwards headers to both fetch and dispatcher.""" from openbb_cli.dispatchers import http as http_mod captured: dict[str, dict[str, str] | None] = {} def fake_fetch( base_url, *, timeout=10.0, path=None, headers=None, query_params=None ): captured["headers"] = headers return {"paths": {"/api/v1/x": {"get": {}}}} monkeypatch.setattr( "openbb_cli.dispatchers.openapi_schema.fetch_openapi", fake_fetch ) d = http_mod.http_dispatcher_from_server( "http://h", headers={"Authorization": "Bearer abc"} ) assert captured["headers"] == {"Authorization": "Bearer abc"} assert d._headers == {"Authorization": "Bearer abc"} @pytest.mark.asyncio async def test_describe_missing_name_returns_error(): """``__schema__`` without ``--name`` returns a structured error.""" d = HttpDispatcher("http://test", spec_doc={"commands": {}}) resp = await d._describe_command(Request(command="__schema__", params={})) assert not resp.ok assert resp.error.type == "MissingParameter" assert "--name" in resp.error.message @pytest.mark.asyncio async def test_describe_unknown_command_returns_error(): """Asking for a command that isn't in the spec returns ``UnknownCommand``.""" d = HttpDispatcher("http://test", spec_doc={"commands": {"a": {}}}) resp = await d._describe_command( Request(command="__schema__", params={"name": "missing.cmd"}) ) assert not resp.ok assert resp.error.type == "UnknownCommand" assert "missing.cmd" in resp.error.message # --- _slim_param branch coverage --- def test_slim_param_drops_empty_choices_and_default_zero(): """Falsy fields (empty choices, missing default) are stripped.""" from openbb_cli.dispatchers.http import _slim_param out = _slim_param( { "name": "x", "in": "query", "type": "integer", "is_list": False, "required": False, "default": None, "choices": [], "help": None, } ) assert out == {"name": "x", "in": "query", "type": "integer"} def test_slim_param_keeps_default_zero(): """``default=0`` is meaningful — must NOT be stripped (only None is).""" from openbb_cli.dispatchers.http import _slim_param out = _slim_param({"name": "x", "in": "query", "type": "integer", "default": 0}) assert out["default"] == 0 def test_slim_param_keeps_choices_and_help_when_set(): from openbb_cli.dispatchers.http import _slim_param out = _slim_param( { "name": "x", "in": "query", "type": "string", "choices": ["a", "b"], "help": "pick", } ) assert out["choices"] == ["a", "b"] assert out["help"] == "pick" def test_slim_param_keeps_json_arg_flag(): """A non-scalar body field carries its ``json_arg`` flag through ``--describe``.""" from openbb_cli.dispatchers.http import _slim_param out = _slim_param( {"name": "data", "in": "body", "type": "string", "json_arg": True} ) assert out["json_arg"] is True def test_partition_params_splits_query_and_body_by_in_tag(): """POST params split by ``in`` tag: query-tagged to the query string, rest to body.""" spec_doc = { "commands": { "x.y": { "parameters": [{"name": "fmt", "in": "query", "type": "string"}], "request_body_schema": { "type": "object", "properties": { "data": {"type": "array", "items": {"type": "object"}} }, }, } } } d = HttpDispatcher("http://t", spec_doc=spec_doc) query, body, headers = d._partition_params( "x.y", {"fmt": "json", "data": [1], "extra": 9} ) assert query == {"fmt": "json"} assert body == {"data": [1], "extra": 9} assert headers == {} def test_partition_params_surfaces_header_tagged_params(): """Params tagged ``in: header`` are routed to the headers map, not query/body.""" spec_doc = { "commands": { "x.y": { "parameters": [ {"name": "x-api-key", "in": "header", "type": "string"}, {"name": "fmt", "in": "query", "type": "string"}, ], } } } d = HttpDispatcher("http://t", spec_doc=spec_doc) query, body, headers = d._partition_params( "x.y", {"x_api_key": "secret", "fmt": "json", "payload": 1} ) assert query == {"fmt": "json"} assert body == {"payload": 1} assert headers == {"x-api-key": "secret"} def test_partition_params_drops_none_header_value(): """A header-tagged param with a ``None`` value is omitted from headers.""" spec_doc = { "commands": { "x.y": { "parameters": [{"name": "x-api-key", "in": "header", "type": "string"}], } } } d = HttpDispatcher("http://t", spec_doc=spec_doc) _query, _body, headers = d._partition_params("x.y", {"x_api_key": None}) assert headers == {} # --- streaming (SSE) --- def test_is_streaming_command_detects_event_stream(): spec_doc = { "commands": { "x.feed": {"response_schemas": {"200": {"text/event-stream": {}}}}, "x.plain": {"response_schemas": {"200": {"application/json": {}}}}, } } d = HttpDispatcher("http://t", spec_doc=spec_doc) assert d._is_streaming_command("x.feed") is True assert d._is_streaming_command("x.plain") is False assert d._is_streaming_command("missing") is False @pytest.mark.asyncio async def test_dispatch_streaming_get_returns_obbstream(): from openbb_core.app.model.stream import OBBStream spec_doc = { "commands": { "x.feed": { "method": "get", "response_schemas": {"200": {"text/event-stream": {}}}, } } } d = HttpDispatcher("http://t", spec_doc=spec_doc, command_methods={"x.feed": "get"}) resp = await d.dispatch(Request(command="x.feed", params={"limit": 2})) await d.aclose() assert resp.ok assert isinstance(resp.result, OBBStream) @pytest.mark.asyncio async def test_dispatch_streaming_post_returns_obbstream(): from openbb_core.app.model.stream import OBBStream spec_doc = { "commands": { "x.feed": { "method": "post", "response_schemas": {"200": {"text/event-stream": {}}}, "request_body_schema": { "type": "object", "properties": {"data": {"type": "array"}}, }, } } } d = HttpDispatcher( "http://t", spec_doc=spec_doc, command_methods={"x.feed": "post"} ) resp = await d.dispatch(Request(command="x.feed", params={"data": [1]})) await d.aclose() assert resp.ok assert isinstance(resp.result, OBBStream) @pytest.mark.asyncio async def test_http_sse_source_iterates_get(monkeypatch): import openbb_cli.dispatchers.http as http_mod from openbb_cli.dispatchers.http import _HttpSSESource captured: dict = {} def handler(request: httpx.Request) -> httpx.Response: captured["method"] = request.method captured["url"] = str(request.url) return httpx.Response(200, text="data: 0\n\ndata: 1\n") real_client = httpx.AsyncClient def make_client(*args, **kwargs): kwargs["transport"] = httpx.MockTransport(handler) return real_client(*args, **kwargs) monkeypatch.setattr(http_mod.httpx, "AsyncClient", make_client) source = _HttpSSESource("http://t/feed", {"limit": 2}, None, "get", None) lines = [line async for line in source.body_iterator] assert captured["method"] == "GET" assert "limit=2" in captured["url"] assert any("data: 0" in line for line in lines) @pytest.mark.asyncio async def test_http_sse_source_iterates_post_with_body(monkeypatch): import openbb_cli.dispatchers.http as http_mod from openbb_cli.dispatchers.http import _HttpSSESource captured: dict = {} def handler(request: httpx.Request) -> httpx.Response: captured["method"] = request.method captured["body"] = request.content.decode() return httpx.Response(200, text="data: 0\n") real_client = httpx.AsyncClient def make_client(*args, **kwargs): kwargs["transport"] = httpx.MockTransport(handler) return real_client(*args, **kwargs) monkeypatch.setattr(http_mod.httpx, "AsyncClient", make_client) source = _HttpSSESource("http://t/feed", None, None, "post", {"data": [1]}) lines = [line async for line in source.body_iterator] assert captured["method"] == "POST" assert "data" in captured["body"] assert any("data: 0" in line for line in lines) @pytest.mark.asyncio async def test_http_sse_source_consumes_warning_header(monkeypatch): import json as _json from openbb_core.app.model.abstract.warning import OpenBBWarning import openbb_cli.dispatchers.http as http_mod from openbb_cli.dispatchers.http import _HttpSSESource def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, text="data: 0\n", headers={ "X-OpenBB-Provider": "fmp", "X-OpenBB-Warning": _json.dumps( [{"category": "OpenBBWarning", "message": "heads up"}] ), }, ) real_client = httpx.AsyncClient def make_client(*args, **kwargs): kwargs["transport"] = httpx.MockTransport(handler) return real_client(*args, **kwargs) monkeypatch.setattr(http_mod.httpx, "AsyncClient", make_client) source = _HttpSSESource("http://t/feed", None, None, "get", None) with pytest.warns(OpenBBWarning, match="heads up"): _lines = [line async for line in source.body_iterator] assert source.provider == "fmp" assert source.warnings is not None assert source.warnings[0]["message"] == "heads up" @pytest.mark.asyncio async def test_http_sse_source_ignores_malformed_warning_header(monkeypatch): import openbb_cli.dispatchers.http as http_mod from openbb_cli.dispatchers.http import _HttpSSESource def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, text="data: 0\n", headers={"X-OpenBB-Warning": "not-json"} ) real_client = httpx.AsyncClient def make_client(*args, **kwargs): kwargs["transport"] = httpx.MockTransport(handler) return real_client(*args, **kwargs) monkeypatch.setattr(http_mod.httpx, "AsyncClient", make_client) source = _HttpSSESource("http://t/feed", None, None, "get", None) _lines = [line async for line in source.body_iterator] assert source.warnings is None # --- _help_for_provider edge: empty section --- def test_help_for_provider_skips_empty_sections_between_separators(): from openbb_cli.dispatchers.http import _help_for_provider text = "shared.;\n ;\n Foo. (provider: fmp)" out = _help_for_provider(text, "fmp") assert out == "shared.\nFoo." # --- _provider_output_schema edges --- def test_provider_output_schema_returns_none_when_results_field_absent(): from openbb_cli.dispatchers.http import _provider_output_schema schema = {"properties": {}} assert _provider_output_schema(schema, "fmp") is None def test_provider_output_schema_skips_unmatched_titles(): """When no oneOf variant matches the provider, return None (not arbitrary first).""" from openbb_cli.dispatchers.http import _provider_output_schema schema = { "properties": { "results": { "anyOf": [ { "items": { "oneOf": [ {"title": "AlphaData", "type": "object"}, {"title": "BetaData", "type": "object"}, ] }, "type": "array", }, {"type": "null"}, ] } } } assert _provider_output_schema(schema, "gamma") is None def test_provider_output_schema_skips_non_dict_oneof_entries(): from openbb_cli.dispatchers.http import _provider_output_schema schema = { "properties": { "results": {"oneOf": ["not-a-dict", {"title": "FMPx", "type": "object"}]} } } out = _provider_output_schema(schema, "fmp") assert out is not None assert out["title"] == "FMPx" # --- _decode_response branches --- def test_decode_response_text_for_xml_content_type(): """Non-JSON content type returns the raw text body.""" from openbb_cli.dispatchers.http import _decode_response response = httpx.Response( 200, text="x", headers={"content-type": "application/xml"} ) assert _decode_response(response) == "x" def test_decode_response_falls_back_to_text_when_json_invalid(): """JSON content type with a body that won't parse falls back to text.""" from openbb_cli.dispatchers.http import _decode_response response = httpx.Response( 200, text="not json", headers={"content-type": "application/json"} ) assert _decode_response(response) == "not json" def test_decode_response_no_content_type_attempts_json(): """Empty content type defaults to JSON parsing.""" from openbb_cli.dispatchers.http import _decode_response response = httpx.Response(200, text='{"a": 1}', headers={"content-type": ""}) assert _decode_response(response) == {"a": 1} # --- _client_context external client branch --- @pytest.mark.asyncio async def test_client_context_yields_external_client_when_not_owned(): """External clients are yielded as-is, not closed by the dispatcher.""" transport = httpx.MockTransport(lambda r: httpx.Response(200, json={})) external = httpx.AsyncClient(transport=transport, base_url="http://t") d = HttpDispatcher("http://t", client=external) async with d._client_context() as c: assert c is external # Not closed response = await external.get("http://t/") assert response.status_code == 200 await external.aclose() def test_slim_param_keeps_is_list_when_true(): """``is_list: True`` lands in the slim output.""" from openbb_cli.dispatchers.http import _slim_param out = _slim_param({"name": "x", "in": "query", "type": "string", "is_list": True}) assert out["is_list"] is True def test_provider_output_schema_falls_back_to_only_candidate_with_no_title_match(): """Single candidate that doesn't title-match the provider is returned anyway.""" from openbb_cli.dispatchers.http import _provider_output_schema schema = { "properties": { "results": { "anyOf": [ {"oneOf": [{"title": "GenericData", "type": "object"}]}, {"type": "null"}, ] } } } out = _provider_output_schema(schema, "fmp") assert out is not None assert out["title"] == "GenericData" @pytest.mark.asyncio async def test_dispatch_routes_list_commands_through_dispatch(): """``dispatch(Request(command="__commands__"))`` reaches the introspection path.""" d = HttpDispatcher( "http://test", spec_doc={"commands": {"foo": {"description": "F"}}}, ) resp = await d.dispatch(Request(command="__commands__")) assert resp.ok assert resp.result == [{"name": "foo", "description": "F"}] @pytest.mark.asyncio async def test_dispatch_routes_describe_through_dispatch(): """``dispatch(Request(command="__schema__"))`` reaches ``_describe_command``.""" d = HttpDispatcher( "http://test", spec_doc={ "commands": { "foo": { "description": "F", "parameters": [], "providers": [], "request_body_schema": None, "response_schema": None, } } }, ) resp = await d.dispatch(Request(command="__schema__", params={"name": "foo"})) assert resp.ok assert resp.result["name"] == "foo" def test_group_by_provider_skips_provider_discriminator(): """The ``provider`` flag itself is filtered out per-provider — already implicit.""" from openbb_cli.dispatchers.http import _group_by_provider raw_params = [ {"name": "provider", "type": "string", "providers": []}, {"name": "symbol", "type": "string", "providers": []}, ] grouped = _group_by_provider(raw_params, ["cboe"], None) names = [p["name"] for p in grouped["cboe"]["parameters"]] assert names == ["symbol"] # ``provider`` itself dropped @pytest.mark.asyncio async def test_dispatch_with_owned_client_uses_fresh_async_client(monkeypatch): """An owned client (no ``client=`` arg) takes the ``owns`` branch in ``_client_context``, constructing a fresh ``httpx.AsyncClient`` per dispatch. """ constructed: list[bool] = [] transport = httpx.MockTransport(lambda r: httpx.Response(200, json={"ok": True})) real_async_client = httpx.AsyncClient def factory(*args, **kwargs): constructed.append(True) kwargs.pop("transport", None) return real_async_client(*args, transport=transport, **kwargs) from openbb_cli.dispatchers import http as http_mod monkeypatch.setattr(http_mod.httpx, "AsyncClient", factory) d = HttpDispatcher("http://t", command_methods={"foo": "get"}) resp = await d.dispatch(Request(command="foo")) assert resp.ok assert constructed == [True] def test_row_column_metadata_extracts_description_format_and_socrata_format(): """``_row_column_metadata`` mirrors codegen's metadata extraction.""" from openbb_cli.dispatchers.http import _row_column_metadata cmd_spec = { "response_schema": { "properties": { "results": { "items": { "properties": { "price": { "type": "number", "description": "Price, measured in dollars per ton", "socrata_format": {"precisionStyle": "standard"}, }, "obs_date": {"type": "string", "format": "date"}, "raw_id": {"type": "string"}, } } } } } } out = _row_column_metadata(cmd_spec) assert out == { "price": { "description": "Price, measured in dollars per ton", "socrata_format": {"precisionStyle": "standard"}, }, "obs_date": {"format": "date"}, } def test_row_column_metadata_returns_empty_when_schema_absent(): """No ``response_schema`` → empty dict, no error.""" from openbb_cli.dispatchers.http import _row_column_metadata assert _row_column_metadata({}) == {} assert _row_column_metadata({"response_schema": None}) == {} assert _row_column_metadata({"response_schema": {"type": "object"}}) == {} @pytest.mark.asyncio async def test_dispatch_injects_column_metadata_into_results_envelope(): """Spec-mode dispatch surfaces column metadata under ``metadata.columns``. Mirrors what the installed-extension fetcher embeds in ``AnnotatedResult.metadata['columns']`` so spec-mode and codegen paths look identical to downstream consumers (e.g. the OBBject ``extra['results_metadata']`` surface). """ spec_doc = { "commands": { "ag.prices": { "method": "get", "response_schema": { "properties": { "results": { "items": { "properties": { "price": { "type": "number", "description": "Price in $/ton", "socrata_format": {"noCommas": "true"}, }, "obs_date": { "type": "string", "format": "date", }, } } } } }, } } } def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, json=[ {"price": "12.5", "obs_date": "2026-04-30T00:00:00.000"}, {"price": "13.0", "obs_date": "2026-04-23T00:00:00.000"}, ], ) transport = httpx.MockTransport(handler) client = httpx.AsyncClient(transport=transport, base_url="http://t") d = HttpDispatcher("http://t", client=client, spec_doc=spec_doc) try: resp = await d.dispatch(Request(command="ag.prices")) finally: await d.aclose() assert resp.ok # Dispatcher returns a live OBBject instance (not a dict) — the # registry can register it directly and JSON serialization still # works through pydantic's recursive model_dump. from openbb_core.app.model.obbject import OBBject assert isinstance(resp.result, OBBject) # Rows coerced (numeric strings → numbers, date timestamps → date). assert resp.result.results == [ {"price": 12.5, "obs_date": "2026-04-30"}, {"price": 13.0, "obs_date": "2026-04-23"}, ] # Column metadata under extra.results_metadata.columns — same path # the installed-extension AnnotatedResult flow produces. assert resp.result.extra["results_metadata"]["columns"] == { "price": { "description": "Price in $/ton", "socrata_format": {"noCommas": "true"}, }, "obs_date": {"format": "date"}, } # Standard OBBject fields are all defaulted. assert resp.result.provider is None assert resp.result.warnings is None assert resp.result.chart is None assert resp.result.id # Tagged base class auto-generates this # Route lives where it canonically belongs — extra.metadata.route — # not duplicated as a synthetic top-level attr. assert resp.result.extra["metadata"].route == "/ag/prices" # ``metadata`` is never a top-level OBBject property. dumped = resp.result.model_dump() assert "metadata" not in dumped @pytest.mark.asyncio async def test_dispatch_skips_metadata_envelope_when_schema_has_none(): """No metadata in schema → no metadata envelope added; raw shape preserved.""" spec_doc = { "commands": { "x": { "method": "get", "response_schema": { "properties": { "results": {"items": {"properties": {"id": {"type": "string"}}}} } }, } } } def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json=[{"id": "a"}, {"id": "b"}]) transport = httpx.MockTransport(handler) client = httpx.AsyncClient(transport=transport, base_url="http://t") d = HttpDispatcher("http://t", client=client, spec_doc=spec_doc) try: resp = await d.dispatch(Request(command="x")) finally: await d.aclose() assert resp.ok # No description / format / socrata_format in the schema → flat list, # no synthetic metadata envelope. assert resp.result == [{"id": "a"}, {"id": "b"}] @pytest.mark.asyncio async def test_dispatch_attaches_execution_metadata_to_obbject(): """Spec-mode wraps in an OBBject AND attaches command_runner-style execution metadata under ``extra['metadata']`` — route, arguments, duration, timestamp. Without this, downstream registries / loggers can't tell what was called, with what args, or how long it took. Mirrors what ``StaticCommandRunner`` writes when ``preferences.metadata`` is on. """ spec_doc = { "commands": { "ag.fertilizer_prices_by_region": { "method": "get", "response_schema": { "properties": { "results": { "items": { "properties": { "price": { "type": "number", "description": "Price in $/ton", } } } } } }, } } } def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json=[{"price": 12.5}]) transport = httpx.MockTransport(handler) client = httpx.AsyncClient(transport=transport, base_url="http://t") d = HttpDispatcher("http://t", client=client, spec_doc=spec_doc) try: resp = await d.dispatch( Request( command="ag.fertilizer_prices_by_region", params={"region": "Cornbelt", "limit": 1}, ) ) finally: await d.aclose() assert resp.ok meta = resp.result.extra["metadata"] # Route uses slash form, mirroring command_runner's convention. assert meta.route == "/ag/fertilizer_prices_by_region" # Arguments organized into the canonical three-key shape. assert meta.arguments == { "provider_choices": {}, "standard_params": {"region": "Cornbelt", "limit": 1}, "extra_params": {}, } # Duration is a positive integer (nanoseconds). assert isinstance(meta.duration, int) and meta.duration > 0 # Timestamp captured. assert meta.timestamp is not None # ``metadata`` is not a top-level OBBject property — only at extra.metadata. dumped = resp.result.model_dump() assert "metadata" not in dumped assert dumped["extra"]["metadata"]["arguments"]["standard_params"] == { "region": "Cornbelt", "limit": 1, } @pytest.mark.asyncio async def test_dispatch_keeps_api_siblings_separate_from_results_metadata(): """API-level sibling fields (NY-Fed ``asOfDate``) and per-column descriptions are *distinct* concepts and ride at distinct keys — sibling fields land directly under ``extra`` (their own key), column hints land under ``extra.results_metadata.columns``. Folding them together would lie about what each piece of data describes. """ spec_doc = { "commands": { "ops.list": { "method": "get", "response_schema": { "properties": { "results": { "items": { "properties": { "id": { "type": "string", "description": "Operation ID", } } } } } }, } } } def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, json={"asOfDate": "2026-04-30", "operations": [{"id": "a"}, {"id": "b"}]}, ) transport = httpx.MockTransport(handler) client = httpx.AsyncClient(transport=transport, base_url="http://t") d = HttpDispatcher("http://t", client=client, spec_doc=spec_doc) try: resp = await d.dispatch(Request(command="ops.list")) finally: await d.aclose() assert resp.ok assert resp.result.results == [{"id": "a"}, {"id": "b"}] # Per-column metadata stays in ``results_metadata.columns``. assert resp.result.extra["results_metadata"]["columns"] == { "id": {"description": "Operation ID"} } # API sibling field at its own top-level extra key — never folded # into ``results_metadata`` (different concept). assert resp.result.extra["asOfDate"] == "2026-04-30" assert "asOfDate" not in resp.result.extra["results_metadata"] # --- Regression coverage for the OBBject-wrap edge cases --- def test_is_plotly_figure_recognizes_canonical_shape(): """A real Plotly figure dict (``data`` list of typed traces + ``layout`` dict) trips the detector — without this guard the generic unpack would tear the figure apart.""" from openbb_cli.dispatchers.http import _is_plotly_figure fig = { "data": [ {"type": "scattergeo", "lat": [0], "lon": [0]}, {"type": "scatter", "x": [1, 2], "y": [3, 4]}, ], "layout": {"title": "Map"}, } assert _is_plotly_figure(fig) is True def test_is_plotly_figure_rejects_lookalikes(): """Conservative: random ``{data, layout}`` payloads from APIs that happen to share the key names but aren't Plotly figures don't trigger the chart wrap.""" from openbb_cli.dispatchers.http import _is_plotly_figure # Not a dict. assert _is_plotly_figure([{"data": [], "layout": {}}]) is False # Missing ``layout``. assert _is_plotly_figure({"data": [{"type": "scatter"}]}) is False # ``layout`` not a dict. assert _is_plotly_figure({"data": [{"type": "scatter"}], "layout": []}) is False # ``data`` not a list. assert _is_plotly_figure({"data": {"type": "scatter"}, "layout": {}}) is False # Empty ``data``. assert _is_plotly_figure({"data": [], "layout": {"x": 1}}) is False # ``data`` items don't carry a ``type`` (look like generic rows). assert _is_plotly_figure({"data": [{"x": 1}], "layout": {"y": 2}}) is False @pytest.mark.asyncio async def test_dispatch_routes_plotly_figure_payload_to_obbject_chart(): """A Plotly-figure-shaped server response lands at ``OBBject.chart.content`` with ``format="plotly"`` — preserves the full figure structure (data + layout) instead of letting the generic unwrap shred it into a list of half-traces. """ from openbb_core.app.model.obbject import OBBject figure = { "data": [ { "type": "scattergeo", "lat": [12.5, 13.0], "lon": [-77.0, -76.5], "name": "Red", } ], "layout": { "title": {"text": "Disruptions"}, "geo": {"projection": "natural earth"}, }, } def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json=figure) transport = httpx.MockTransport(handler) client = httpx.AsyncClient(transport=transport, base_url="http://t") d = HttpDispatcher("http://t", client=client, command_methods={"chart_cmd": "get"}) try: resp = await d.dispatch( Request(command="chart_cmd", params={"region": "global"}) ) finally: await d.aclose() assert resp.ok assert isinstance(resp.result, OBBject) # The figure rides at BOTH ``results`` and ``chart.content`` so # callers reading ``obbject.results`` see the actual figure (not # ``None``), and chart-aware renderers (``obbject.charting.show()``) # also keep working off the canonical ``chart`` slot. assert resp.result.results == figure assert resp.result.chart is not None assert resp.result.chart.format == "plotly" assert resp.result.chart.content == figure # Execution metadata still rides under ``extra.metadata`` like # every other dispatch. meta = resp.result.extra["metadata"] assert meta.route == "/chart_cmd" assert meta.arguments["standard_params"] == {"region": "global"} @pytest.mark.asyncio async def test_dispatch_plotly_figure_falls_back_to_raw_dict_without_openbb_core( monkeypatch, ): """Without ``openbb_core``, the Plotly wrap can't construct an OBBject. Fall back to returning the raw figure dict so the figure structure is still preserved end-to-end.""" import builtins real_import = builtins.__import__ def _block_chart_imports(name, *args, **kwargs): if name.startswith("openbb_core.app.model"): raise ImportError(f"simulated missing {name}") return real_import(name, *args, **kwargs) monkeypatch.setattr(builtins, "__import__", _block_chart_imports) figure = { "data": [{"type": "scatter", "x": [1], "y": [2]}], "layout": {"title": "x"}, } def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json=figure) transport = httpx.MockTransport(handler) client = httpx.AsyncClient(transport=transport, base_url="http://t") d = HttpDispatcher("http://t", client=client, command_methods={"x": "get"}) try: resp = await d.dispatch(Request(command="x")) finally: await d.aclose() # Raw figure dict preserved — both data and layout intact. assert resp.result == figure def test_command_to_route_translates_dotted_to_slash_form(): """Dotted command paths land in ``Metadata.route`` as the slash form ``command_runner`` uses, with a leading slash.""" from openbb_cli.dispatchers.http import _command_to_route assert _command_to_route("equity.price.historical") == "/equity/price/historical" assert _command_to_route("law") == "/law" # Strips redundant slashes so callers don't have to normalize. assert _command_to_route("/foo.bar/") == "/foo/bar" @pytest.mark.asyncio async def test_shape_and_coerce_returns_payload_unchanged_for_non_collection(): """A scalar / non-list / non-dict shape from ``_shape_result`` short- circuits the wrap — there's no row payload to type-coerce or wrap.""" spec_doc = { "commands": { "x": { "method": "get", "response_schema": { "properties": { "results": {"items": {"properties": {"id": {"type": "string"}}}} } }, } } } def handler(request: httpx.Request) -> httpx.Response: # ``"plain text"`` is not JSON content-type, falls through as-is. return httpx.Response( 200, text="plain text", headers={"content-type": "text/plain"} ) transport = httpx.MockTransport(handler) client = httpx.AsyncClient(transport=transport, base_url="http://t") d = HttpDispatcher("http://t", client=client, spec_doc=spec_doc) try: resp = await d.dispatch(Request(command="x")) finally: await d.aclose() assert resp.ok # Non-JSON text bypasses unwrap entirely — stays a plain string. assert resp.result == "plain text" @pytest.mark.asyncio async def test_dispatch_handles_single_row_envelope_with_sibling_metadata(): """Sibling-metadata API returning a single-row array — after unpack + ``_shape_result``'s single-row collapse, the inner ``results`` field arrives at ``_shape_and_coerce`` as a single dict (not a list). Exercises the ``elif inner is not None`` branch. """ from openbb_core.app.model.obbject import OBBject spec_doc = { "commands": { "y": { "method": "get", "response_schema": { "properties": { "results": {"items": {"properties": {"x": {"type": "number"}}}} } }, } } } def handler(request: httpx.Request) -> httpx.Response: # ``operations`` (single row) + sibling ``asOfDate`` → # ``unpack_response`` extracts metadata, ``_shape_result`` # collapses the single-row list, and ``_shape_and_coerce`` sees # ``{results: {dict}, metadata: {asOfDate}}``. return httpx.Response( 200, json={"asOfDate": "2026-04-30", "operations": [{"x": "1.5"}]} ) transport = httpx.MockTransport(handler) client = httpx.AsyncClient(transport=transport, base_url="http://t") d = HttpDispatcher("http://t", client=client, spec_doc=spec_doc) try: resp = await d.dispatch(Request(command="y")) finally: await d.aclose() assert resp.ok # Wrap produces a real OBBject; the sibling ``asOfDate`` lands at # ``extra.asOfDate`` (its own concept), separate from per-row # metadata. assert isinstance(resp.result, OBBject) assert resp.result.extra["asOfDate"] == "2026-04-30" # Type coercion ran through the single-row branch — string ``"1.5"`` # → float. assert resp.result.results == {"x": 1.5} @pytest.mark.asyncio async def test_dispatch_obbject_wrap_falls_back_to_dict_when_openbb_core_missing( monkeypatch, ): """Without ``openbb_core`` available, the wrap can't construct a real OBBject — fall back to a plain dict that still puts metadata under ``extra`` so the schema never sprouts a top-level ``metadata`` field. """ import builtins real_import = builtins.__import__ def _block_openbb_core(name, *args, **kwargs): if name.startswith("openbb_core.app.model"): raise ImportError(f"simulated missing {name}") return real_import(name, *args, **kwargs) monkeypatch.setattr(builtins, "__import__", _block_openbb_core) spec_doc = { "commands": { "z": { "method": "get", "response_schema": { "properties": { "results": { "items": { "properties": { "id": { "type": "string", "description": "An ID", } } } } } }, } } } def handler(request: httpx.Request) -> httpx.Response: # Two rows — avoid the single-row collapse so we can assert on # the list shape unambiguously. return httpx.Response(200, json=[{"id": "a"}, {"id": "b"}]) transport = httpx.MockTransport(handler) client = httpx.AsyncClient(transport=transport, base_url="http://t") d = HttpDispatcher("http://t", client=client, spec_doc=spec_doc) try: resp = await d.dispatch(Request(command="z")) finally: await d.aclose() assert resp.ok # Plain dict fallback — but metadata still under ``extra``, never # at top level. assert isinstance(resp.result, dict) assert "metadata" not in resp.result assert resp.result["results"] == [{"id": "a"}, {"id": "b"}] assert resp.result["extra"]["results_metadata"]["columns"] == { "id": {"description": "An ID"} } @pytest.mark.asyncio async def test_dispatch_metadata_construction_failure_does_not_kill_response(): """Telemetry is best-effort — when ``Metadata`` construction raises (e.g. malformed timestamp), the OBBject still gets returned with the rest of its metadata intact. """ from openbb_core.app.model.obbject import OBBject spec_doc = { "commands": { "w": { "method": "get", "response_schema": { "properties": { "results": { "items": { "properties": { "id": { "type": "string", "description": "An ID", } } } } } }, } } } def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json=[{"id": "a"}]) # Patch ``Metadata`` to raise so we exercise the suppress branch. import openbb_core.app.model.metadata as metadata_mod def _raise(*args, **kwargs): raise RuntimeError("simulated metadata failure") transport = httpx.MockTransport(handler) client = httpx.AsyncClient(transport=transport, base_url="http://t") d = HttpDispatcher("http://t", client=client, spec_doc=spec_doc) original = metadata_mod.Metadata metadata_mod.Metadata = _raise # type: ignore[assignment] try: resp = await d.dispatch(Request(command="w")) finally: metadata_mod.Metadata = original # type: ignore[assignment] await d.aclose() assert resp.ok assert isinstance(resp.result, OBBject) # Column metadata still surfaced; ``extra.metadata`` simply absent # because telemetry construction failed and got suppressed. assert resp.result.extra["results_metadata"]["columns"] == { "id": {"description": "An ID"} } assert "metadata" not in resp.result.extra # --- Helper-function coverage --- def test_user_limit_for_per_item_handles_none_invalid_negative_zero(): """``_user_limit_for_per_item`` returns ``None`` for missing / non-numeric / negative / zero limits — the per-item collapse pass treats all of these the same way (no cap).""" from openbb_cli.dispatchers.http import _user_limit_for_per_item # No limit at all → ``None``. assert _user_limit_for_per_item(Request(command="x", params={})) is None # Non-numeric → ``None`` via the ``ValueError`` branch. assert ( _user_limit_for_per_item(Request(command="x", params={"limit": "abc"})) is None ) # Negative → ``None`` (not a real cap). assert _user_limit_for_per_item(Request(command="x", params={"limit": -5})) is None # Zero → ``None`` (means "every row" — the collapse runs uncapped). assert _user_limit_for_per_item(Request(command="x", params={"limit": 0})) is None # Positive → integer. assert _user_limit_for_per_item(Request(command="x", params={"limit": 5})) == 5 def test_request_with_page_size_limit_caps_user_limit_to_one_page(): """``_request_with_page_size_limit`` clobbers ``limit`` with the page-size constant so the most-recent-per-item path issues exactly one page-sized fetch regardless of what the user asked for.""" from openbb_cli.dispatchers.http import ( _SOCRATA_PAGE_SIZE, _request_with_page_size_limit, ) out = _request_with_page_size_limit( Request(id="abc", command="x", params={"limit": 3, "region": "Tampa"}) ) assert out.id == "abc" assert out.params["limit"] == _SOCRATA_PAGE_SIZE assert out.params["region"] == "Tampa" def test_truncate_to_top_n_dates_handles_non_dict_rows_and_missing_dates(): """Non-dict rows and rows missing the time-axis value are passed through unchanged — only rows with a real date count toward the distinct-date cap.""" from openbb_cli.dispatchers.http import _truncate_to_top_n_dates rows = [ "scalar-row", # non-dict, kept verbatim {"date": "2026-04-30", "x": 1}, {"date": None, "x": 2}, # missing date, kept verbatim {"date": "2026-04-30", "x": 3}, # same date, kept (still under cap) {"date": "2026-04-29", "x": 4}, # second date — fills the cap {"date": "2026-04-28", "x": 5}, # third date — break before this row ] out = _truncate_to_top_n_dates(rows, "date", 2) # Three "real" date rows kept (two in date 2026-04-30, one in 2026-04-29); # the non-dict scalar and missing-date row also kept; the third-date row # is dropped. assert "scalar-row" in out assert {"date": None, "x": 2} in out assert {"date": "2026-04-30", "x": 1} in out assert {"date": "2026-04-30", "x": 3} in out assert {"date": "2026-04-29", "x": 4} in out assert {"date": "2026-04-28", "x": 5} not in out def test_truncate_to_top_n_dates_returns_unchanged_when_user_limit_is_none(): """``user_limit=None`` (the ``limit=0`` case) means no truncation — the rows pass through verbatim.""" from openbb_cli.dispatchers.http import _truncate_to_top_n_dates rows = [{"date": "2026-04-30"}, {"date": "2026-04-01"}] assert _truncate_to_top_n_dates(rows, "date", None) == rows # Non-list shape with no extractable rows also passes through. assert _truncate_to_top_n_dates("scalar", "date", 5) == "scalar" def test_extract_rows_handles_duck_typed_obbject(): """``_extract_rows`` recognizes a duck-typed OBBject (``results`` list + ``extra``) without importing ``openbb_core``. Other shapes return ``None``. """ from openbb_cli.dispatchers.http import _extract_rows class _DuckOBBject: results = [{"a": 1}, {"a": 2}] extra: dict = {} assert _extract_rows(_DuckOBBject()) == [{"a": 1}, {"a": 2}] # ``{results: [...]}`` envelope dict → returns the inner list. assert _extract_rows({"results": [{"x": 1}]}) == [{"x": 1}] # No ``extra`` attr → not enough fingerprint, returns ``None``. class _Almost: results = [{"a": 1}] assert _extract_rows(_Almost()) is None # Plain string scalar → ``None``. assert _extract_rows("scalar") is None # Dict without a ``results`` list value → ``None``. assert _extract_rows({"x": 1}) is None assert _extract_rows({"results": "not-a-list"}) is None def test_replace_rows_substitutes_into_envelope_dict(): """A ``{results: [...]}`` envelope dict with extra siblings preserves the siblings and only replaces the ``results`` key. Exercises the dict branch at line 441.""" from openbb_cli.dispatchers.http import _replace_rows out = _replace_rows({"results": [{"old": 1}], "metadata": {"k": "v"}}, [{"new": 1}]) assert out == {"results": [{"new": 1}], "metadata": {"k": "v"}} def test_replace_rows_returns_shaped_for_unrecognized_object(): """An object with neither ``results`` (as a list) nor ``extra`` falls through the OBBject duck-type and gets returned verbatim.""" from openbb_cli.dispatchers.http import _replace_rows class _NotAnOBBject: results = "not-a-list" # disqualifies the duck-type fingerprint obj = _NotAnOBBject() assert _replace_rows(obj, [{"a": 1}]) is obj def test_replace_rows_mutates_obbject_in_place_and_returns_it(): """``_replace_rows`` mutates an OBBject's ``results`` in place, preserving the original instance (and its private attrs) — important so registry insertion later sees the canonical instance.""" from openbb_core.app.model.obbject import OBBject from openbb_cli.dispatchers.http import _replace_rows obb = OBBject(results=[{"old": 1}], extra={"x": 1}) obb._route = "/r" new_rows = [{"new": 1}, {"new": 2}] out = _replace_rows(obb, new_rows) assert out is obb # same instance assert obb.results == new_rows assert obb._route == "/r" # private attr survived def test_replace_rows_returns_unchanged_for_unrecognized_shape(): """A shape that's neither list / dict-with-results / duck-OBBject is returned verbatim — there's no row slot to substitute into.""" from openbb_cli.dispatchers.http import _replace_rows assert _replace_rows("scalar", [{"a": 1}]) == "scalar" assert _replace_rows({"no_results_key": True}, [{"a": 1}]) == { "no_results_key": True } def test_coerce_response_value_numeric_parse_failure_returns_string(): """Numeric coercion treats ``ValueError`` from ``float()`` as a signal to return the raw string — protects against malformed wire payloads sneaking through to downstream consumers as ``NaN``.""" from openbb_cli.dispatchers.http import _coerce_response_value # ``"infinity-but-not"`` → float() raises → string passthrough. assert _coerce_response_value("not-a-number", "number", "") == "not-a-number" def test_coerce_response_value_handles_integer_boolean_and_date_branches(): """``_coerce_response_value`` coerces wire strings into typed values across each declared schema type.""" from openbb_cli.dispatchers.http import _coerce_response_value # Integer happy-path. assert _coerce_response_value("42", "integer", "") == 42 # Integer parse fail → string passthrough. assert _coerce_response_value("not-an-int", "integer", "") == "not-an-int" # Boolean happy-path (case-insensitive). assert _coerce_response_value("True", "boolean", "") is True assert _coerce_response_value("FALSE", "boolean", "") is False # Boolean unrecognized → string passthrough. assert _coerce_response_value("maybe", "boolean", "") == "maybe" # Date drop time component when the string contains a ``T`` and is long # enough to look like an ISO timestamp. assert _coerce_response_value("2026-04-30T00:00:00.000", "string", "date") == ( "2026-04-30" ) # Already a plain date string, no ``T`` → unchanged. assert _coerce_response_value("2026-04-30", "string", "date") == "2026-04-30" # Non-string input → unchanged. assert _coerce_response_value(42, "integer", "") == 42 def test_row_column_types_skips_non_dict_property_entries(): """Defensive: a non-dict entry in ``items.properties`` (malformed schema) gets skipped instead of crashing the whole walk.""" from openbb_cli.dispatchers.http import _row_column_types cmd_spec = { "response_schema": { "properties": { "results": { "items": { "properties": { "ok": {"type": "string"}, "garbage": "not-a-dict", } } } } } } out = _row_column_types(cmd_spec) assert "ok" in out assert "garbage" not in out def test_row_column_metadata_skips_non_dict_property_entries(): """Same defensive skip for ``_row_column_metadata`` so codegen-style schemas can't trip the column-metadata extraction.""" from openbb_cli.dispatchers.http import _row_column_metadata cmd_spec = { "response_schema": { "properties": { "results": { "items": { "properties": { "ok": {"type": "string", "description": "Real"}, "garbage": "not-a-dict", } } } } } } out = _row_column_metadata(cmd_spec) assert "ok" in out assert "garbage" not in out def test_coerce_row_types_handles_empty_types_non_dict_rows_and_unknown_fields(): """Three branches of ``_coerce_row_types``: * empty ``column_types`` → no-op pass-through * non-dict row → kept verbatim * row field absent from ``column_types`` → value passed through unchanged """ from openbb_cli.dispatchers.http import _coerce_row_types # Empty types — no-op. assert _coerce_row_types([{"a": "1"}], {}) == [{"a": "1"}] # Non-dict row — kept verbatim. out = _coerce_row_types(["scalar", {"a": "1"}], {"a": ("integer", "")}) assert out == ["scalar", {"a": 1}] # Field not in column_types — passed through unchanged. out = _coerce_row_types([{"a": "1", "b": "stuff"}], {"a": ("integer", "")}) assert out == [{"a": 1, "b": "stuff"}] @pytest.mark.asyncio async def test_apply_param_transforms_folds_socrata_date_range_into_where(): """``_apply_param_transforms`` rewrites ``start_date`` / ``end_date`` params into a SoQL ``$where`` clause and applies ``wire_name`` renames so the spec-mode dispatcher hits the upstream API with the same URL the installed-extension fetcher would build. """ # No ``_socrata_time_axis`` — keeps the test off the date-snapshot # pagination path so the user's ``limit`` survives intact for the # ``$limit`` rename assertion. spec_doc = { "commands": { "ag.prices": { "method": "get", "parameters": [ { "name": "start_date", "_socrata_op": "date_min", "_socrata_column": "obs_date", }, { "name": "end_date", "_socrata_op": "date_max", "_socrata_column": "obs_date", }, {"name": "limit", "wire_name": "$limit"}, "not-a-dict", # defensive non-dict skip ], } } } captured: dict = {} def handler(request: httpx.Request) -> httpx.Response: captured["params"] = dict(request.url.params) return httpx.Response(200, json=[]) transport = httpx.MockTransport(handler) client = httpx.AsyncClient(transport=transport, base_url="http://t") d = HttpDispatcher( "http://t", client=client, spec_doc=spec_doc, command_methods={"ag.prices": "get"}, ) try: await d.dispatch( Request( command="ag.prices", params={ "start_date": "2026-01-01", "end_date": "2026-04-30", "limit": 10, }, ) ) finally: await d.aclose() # Where-fold and wire_name rename applied. assert ( captured["params"]["$where"] == "obs_date >= '2026-01-01' AND obs_date <= '2026-04-30'" ) assert captured["params"]["$limit"] == "10" # User-facing names removed in favour of the wire form. assert "start_date" not in captured["params"] assert "end_date" not in captured["params"] assert "limit" not in captured["params"] # No default $order without a ``_socrata_time_axis``. assert "$order" not in captured["params"] @pytest.mark.asyncio async def test_apply_param_transforms_default_order_with_time_axis_set(): """A spec command with ``_socrata_time_axis`` gets ``$order= DESC`` auto-injected so ``limit=N`` returns the most-recent N rows by default. User-supplied ``$order`` overrides it. """ spec_doc = { "commands": { "x": { "method": "get", "_socrata_time_axis": "obs_date", "parameters": [{"name": "foo"}], } } } captured: dict = {"calls": []} def handler(request: httpx.Request) -> httpx.Response: captured["calls"].append(dict(request.url.params)) return httpx.Response(200, json=[]) transport = httpx.MockTransport(handler) client = httpx.AsyncClient(transport=transport, base_url="http://t") d = HttpDispatcher( "http://t", client=client, spec_doc=spec_doc, command_methods={"x": "get"}, ) try: # No user $order → auto-injected. await d.dispatch(Request(command="x", params={"foo": "bar"})) # User-supplied $order overrides the default. await d.dispatch( Request(command="x", params={"foo": "bar", "$order": "obs_date ASC"}) ) finally: await d.aclose() assert captured["calls"][0]["$order"] == "obs_date DESC" assert captured["calls"][1]["$order"] == "obs_date ASC" @pytest.mark.asyncio async def test_apply_param_transforms_combines_existing_where_with_date_fold(): """User-supplied ``$where`` + folded date-range get AND-combined into a single SoQL clause, parenthesized to preserve precedence.""" spec_doc = { "commands": { "x": { "method": "get", "parameters": [ { "name": "start_date", "_socrata_op": "date_min", "_socrata_column": "obs_date", }, ], } } } captured: dict = {} def handler(request: httpx.Request) -> httpx.Response: captured["params"] = dict(request.url.params) return httpx.Response(200, json=[]) transport = httpx.MockTransport(handler) client = httpx.AsyncClient(transport=transport, base_url="http://t") d = HttpDispatcher( "http://t", client=client, spec_doc=spec_doc, command_methods={"x": "get"}, ) try: await d.dispatch( Request( command="x", params={"start_date": "2026-01-01", "$where": "region = 'Tampa'"}, ) ) finally: await d.aclose() assert ( captured["params"]["$where"] == "(region = 'Tampa') AND (obs_date >= '2026-01-01')" ) @pytest.mark.asyncio async def test_apply_param_transforms_skips_date_range_when_user_omits_value(): """A date-range param the user didn't supply gets skipped — no ``$where`` clause appended for it. Exercises the ``value is None`` continue at line 882.""" spec_doc = { "commands": { "x": { "method": "get", "parameters": [ { "name": "start_date", "_socrata_op": "date_min", "_socrata_column": "obs_date", }, ], } } } captured: dict = {} def handler(request: httpx.Request) -> httpx.Response: captured["params"] = dict(request.url.params) return httpx.Response(200, json=[]) transport = httpx.MockTransport(handler) client = httpx.AsyncClient(transport=transport, base_url="http://t") d = HttpDispatcher( "http://t", client=client, spec_doc=spec_doc, command_methods={"x": "get"}, ) try: # No ``start_date`` in params → the date-range fold sees no # value and skips, leaving no ``$where`` clause. await d.dispatch(Request(command="x", params={"foo": "bar"})) finally: await d.aclose() assert "$where" not in captured["params"] @pytest.mark.asyncio async def test_apply_param_transforms_skips_date_range_when_socrata_column_missing(): """A date-range param flagged with ``_socrata_op`` but missing the ``_socrata_column`` (malformed spec) gets skipped — no fabricated where clause. Exercises the ``not column`` continue at line 885.""" spec_doc = { "commands": { "x": { "method": "get", "parameters": [ { "name": "start_date", "_socrata_op": "date_min", # ``_socrata_column`` deliberately absent. }, ], } } } captured: dict = {} def handler(request: httpx.Request) -> httpx.Response: captured["params"] = dict(request.url.params) return httpx.Response(200, json=[]) transport = httpx.MockTransport(handler) client = httpx.AsyncClient(transport=transport, base_url="http://t") d = HttpDispatcher( "http://t", client=client, spec_doc=spec_doc, command_methods={"x": "get"}, ) try: await d.dispatch(Request(command="x", params={"start_date": "2026-01-01"})) finally: await d.aclose() # Where-clause never built; the user's start_date param ends up # consumed (popped during the fold attempt) so it doesn't leak to # the URL either. assert "$where" not in captured["params"] @pytest.mark.asyncio async def test_apply_param_transforms_no_op_when_spec_has_no_parameters(): """No parameters declared on the command → no transform; params pass through to the URL exactly as supplied.""" spec_doc = {"commands": {"x": {"method": "get", "parameters": []}}} captured: dict = {} def handler(request: httpx.Request) -> httpx.Response: captured["params"] = dict(request.url.params) return httpx.Response(200, json=[]) transport = httpx.MockTransport(handler) client = httpx.AsyncClient(transport=transport, base_url="http://t") d = HttpDispatcher( "http://t", client=client, spec_doc=spec_doc, command_methods={"x": "get"}, ) try: await d.dispatch(Request(command="x", params={"foo": "bar"})) finally: await d.aclose() assert captured["params"]["foo"] == "bar" @pytest.mark.asyncio async def test_fetch_until_n_distinct_dates_paginates_and_breaks_on_short_page(): """``_fetch_until_n_distinct_dates`` pages until either ``n+1`` distinct dates are seen OR the upstream returns a short page, whichever comes first.""" from openbb_cli.dispatchers.http import ( _SOCRATA_PAGE_SIZE, _fetch_until_n_distinct_dates, ) pages = [ # First page has ``_SOCRATA_PAGE_SIZE`` rows but only one distinct # date — must keep paging. [{"date": "2026-04-30", "x": i} for i in range(_SOCRATA_PAGE_SIZE)], # Short second page introduces a second distinct date AND signals # end-of-data via the < page-size length. [{"date": "2026-04-29", "x": 1}, {"date": "2026-04-29", "x": 2}], ] call_count = {"n": 0} def handler(request: httpx.Request) -> httpx.Response: i = call_count["n"] call_count["n"] += 1 if i < len(pages): return httpx.Response(200, json=pages[i]) return httpx.Response(200, json=[]) transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport, base_url="http://t") as client: rows = await _fetch_until_n_distinct_dates( client, "http://t/x", {}, None, "date", 1 ) # Both pages consumed; short page breaks the loop. assert call_count["n"] == 2 assert len(rows) == _SOCRATA_PAGE_SIZE + 2 @pytest.mark.asyncio async def test_fetch_until_n_distinct_dates_breaks_on_empty_first_page(): """Empty first page → exit immediately. Exercises the ``not isinstance(payload, list) or not payload`` break.""" from openbb_cli.dispatchers.http import _fetch_until_n_distinct_dates call_count = {"n": 0} def handler(request: httpx.Request) -> httpx.Response: call_count["n"] += 1 return httpx.Response(200, json=[]) transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport, base_url="http://t") as client: rows = await _fetch_until_n_distinct_dates( client, "http://t/x", {}, None, "date", 5 ) assert rows == [] assert call_count["n"] == 1 @pytest.mark.asyncio async def test_fetch_until_n_distinct_dates_breaks_on_short_page_before_n_plus_one(): """A page shorter than ``_SOCRATA_PAGE_SIZE`` arrives before the (n+1)-th distinct date is seen → exit via the short-page break.""" from openbb_cli.dispatchers.http import _fetch_until_n_distinct_dates # Page of 5 rows (way under page-size cap), all sharing the same date — # exits via the ``len(payload) < _SOCRATA_PAGE_SIZE`` branch before # the n+1 break. def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, json=[{"date": "2026-04-30", "x": i} for i in range(5)], ) transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport, base_url="http://t") as client: rows = await _fetch_until_n_distinct_dates( client, "http://t/x", {}, None, "date", 10 ) assert len(rows) == 5 @pytest.mark.asyncio async def test_fetch_until_n_distinct_dates_breaks_when_n_plus_one_seen(): """Once we've seen ``n+1`` distinct dates, the loop exits even if the upstream still has more rows.""" from openbb_cli.dispatchers.http import _fetch_until_n_distinct_dates page = [ {"date": "2026-04-30", "x": 1}, {"date": "2026-04-29", "x": 2}, # distinct #2 — exceeds n=1. # Skipped — non-dict row in payload triggers the defensive # skip in the date-collection loop too. "garbage", ] def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json=page) transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport, base_url="http://t") as client: rows = await _fetch_until_n_distinct_dates( client, "http://t/x", {}, None, "date", 1 ) # Single fetch sufficed because n+1 dates appeared. assert {row.get("date") if isinstance(row, dict) else None for row in rows} == { "2026-04-30", "2026-04-29", None, } @pytest.mark.asyncio async def test_fetch_all_pages_paginates_until_short_page_or_empty(): """``_fetch_all_pages`` keeps requesting until the upstream returns a short page OR an empty list (the ``limit=0`` semantic).""" from openbb_cli.dispatchers.http import _SOCRATA_PAGE_SIZE, _fetch_all_pages pages = [ [{"x": i} for i in range(_SOCRATA_PAGE_SIZE)], # Short page — terminates the loop. [{"x": _SOCRATA_PAGE_SIZE}], ] call_count = {"n": 0} def handler(request: httpx.Request) -> httpx.Response: i = call_count["n"] call_count["n"] += 1 if i < len(pages): return httpx.Response(200, json=pages[i]) return httpx.Response(200, json=[]) transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport, base_url="http://t") as client: rows = await _fetch_all_pages(client, "http://t/x", {}, None) assert call_count["n"] == 2 assert len(rows) == _SOCRATA_PAGE_SIZE + 1 @pytest.mark.asyncio async def test_fetch_all_pages_terminates_on_empty_first_page(): """Empty first page → loop exits immediately, no second request.""" from openbb_cli.dispatchers.http import _fetch_all_pages call_count = {"n": 0} def handler(request: httpx.Request) -> httpx.Response: call_count["n"] += 1 return httpx.Response(200, json=[]) transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport, base_url="http://t") as client: rows = await _fetch_all_pages(client, "http://t/x", {}, None) assert rows == [] assert call_count["n"] == 1 @pytest.mark.asyncio async def test_dispatch_engages_fetch_all_when_limit_zero(): """``limit=0`` flips the dispatch into the all-pages path — exercises the ``fetch_all`` branch in ``dispatch``. """ from openbb_cli.dispatchers.http import _SOCRATA_PAGE_SIZE pages = [ [{"id": i} for i in range(_SOCRATA_PAGE_SIZE)], [{"id": _SOCRATA_PAGE_SIZE}], ] call_count = {"n": 0} def handler(request: httpx.Request) -> httpx.Response: i = call_count["n"] call_count["n"] += 1 if i < len(pages): return httpx.Response(200, json=pages[i]) return httpx.Response(200, json=[]) transport = httpx.MockTransport(handler) client = httpx.AsyncClient(transport=transport, base_url="http://t") d = HttpDispatcher( "http://t", client=client, command_methods={"x": "get"}, ) try: resp = await d.dispatch(Request(command="x", params={"limit": 0})) finally: await d.aclose() assert resp.ok assert call_count["n"] == 2 # Two pages, then short-page break. @pytest.mark.asyncio async def test_dispatch_engages_date_snapshot_truncation_when_time_axis_present(): """Spec command with a ``_socrata_time_axis`` + a positive ``limit`` engages the date-snapshot path: pagination via ``_fetch_until_n_distinct_dates`` then local truncation via ``_truncate_to_top_n_dates``. """ spec_doc = { "commands": { "ds": { "method": "get", "_socrata_time_axis": "obs_date", "parameters": [], } } } def handler(request: httpx.Request) -> httpx.Response: # Three distinct dates in a single short page so the n+1 break and # short-page break both fire. return httpx.Response( 200, json=[ {"obs_date": "2026-04-30", "x": 1}, {"obs_date": "2026-04-30", "x": 2}, {"obs_date": "2026-04-29", "x": 3}, {"obs_date": "2026-04-28", "x": 4}, ], ) transport = httpx.MockTransport(handler) client = httpx.AsyncClient(transport=transport, base_url="http://t") d = HttpDispatcher( "http://t", client=client, spec_doc=spec_doc, command_methods={"ds": "get"}, ) try: # ``limit=2`` → keep top-2 most recent distinct dates. resp = await d.dispatch(Request(command="ds", params={"limit": 2})) finally: await d.aclose() assert resp.ok # Two-date truncation: kept rows for 2026-04-30 and 2026-04-29; dropped 2026-04-28. distinct = {row["obs_date"] for row in resp.result if isinstance(row, dict)} assert distinct == {"2026-04-30", "2026-04-29"} @pytest.mark.asyncio async def test_dispatch_date_snapshot_skipped_when_limit_zero_or_invalid(): """``limit=0`` or non-numeric ``limit`` short-circuits the date- snapshot context — no truncation, no special pagination.""" spec_doc = { "commands": { "ds": { "method": "get", "_socrata_time_axis": "obs_date", } } } def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json=[{"obs_date": "2026-04-30"}]) transport = httpx.MockTransport(handler) client = httpx.AsyncClient(transport=transport, base_url="http://t") d = HttpDispatcher("http://t", client=client, spec_doc=spec_doc) try: # ``limit=0`` short-circuits — no truncation engaged. await d.dispatch(Request(command="ds", params={"limit": 0})) # Non-numeric short-circuits via the int() ValueError path. await d.dispatch(Request(command="ds", params={"limit": "not-a-number"})) # No-limit also short-circuits via the ``raw_limit is None`` path. await d.dispatch(Request(command="ds", params={})) finally: await d.aclose()