From 90d427c5a520e134cf2549ea9783e3cfd2a8c9e6 Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:26:28 -0700 Subject: [PATCH] restore test coverage to 100% in openbb_core --- .../openbb_core/provider/utils/helpers.py | 4 +- .../api/test_router/test_commands_helpers.py | 30 ++++++ .../test_docstring_generator_helpers.py | 53 +++++++++++ .../test_method_definition_helpers.py | 95 +++++++++++++++++++ .../package_builder/test_path_handler.py | 41 ++++++++ .../app/static/test_import_definition.py | 39 +++++++- .../core/tests/app/test_platform_router.py | 20 ++++ .../core/tests/app/test_route_iter.py | 26 +++++ .../core/tests/provider/test_registry_map.py | 29 ++++++ .../core/tests/provider/utils/test_helpers.py | 30 ++++++ 10 files changed, 361 insertions(+), 6 deletions(-) create mode 100644 openbb_platform/core/tests/app/test_route_iter.py diff --git a/openbb_platform/core/openbb_core/provider/utils/helpers.py b/openbb_platform/core/openbb_core/provider/utils/helpers.py index 4142040650d..a7d4532f77c 100644 --- a/openbb_platform/core/openbb_core/provider/utils/helpers.py +++ b/openbb_platform/core/openbb_core/provider/utils/helpers.py @@ -496,8 +496,8 @@ def combine_certificates(cert: str, bundle: str | None = None) -> str: with open(cert, "rb") as cert_file: shutil.copyfileobj(cert_file, combined_cert_file) - # Register the combined certificate file for deletion - atexit.register(os.remove, combined_cert) + # Register the combined certificate file for deletion. + atexit.register(Path(combined_cert).unlink, missing_ok=True) return combined_cert except Exception as e: diff --git a/openbb_platform/core/tests/api/test_router/test_commands_helpers.py b/openbb_platform/core/tests/api/test_router/test_commands_helpers.py index 4e0722f9c52..345c954f10c 100644 --- a/openbb_platform/core/tests/api/test_router/test_commands_helpers.py +++ b/openbb_platform/core/tests/api/test_router/test_commands_helpers.py @@ -534,3 +534,33 @@ def test_add_command_map_wraps_and_includes_plugin_routes(monkeypatch): getattr(route, "path", None) == "/plugin" for route in iter_api_routes(api_router) ) + + +def test_add_command_map_skips_non_apiroute_leaf(monkeypatch): + """A resolved route whose leaf is not an ``APIRoute`` is skipped, not wrapped.""" + from fastapi import APIRouter + + plugins_api_router = APIRouter() + plugins_router = type("PR", (), {"api_router": plugins_api_router})() + + # Not an APIRoute -> `build_api_wrapper` must never be reached. + mount = MagicMock(spec=[]) + + monkeypatch.setattr( + "openbb_core.api.router.commands.RouterLoader.from_extensions", + lambda: plugins_router, + ) + monkeypatch.setattr( + "openbb_core.api.router.commands.iter_api_routes", + lambda _router: iter([mount]), + ) + + def _boom(command_runner, route): + raise AssertionError("build_api_wrapper should not be called") + + monkeypatch.setattr("openbb_core.api.router.commands.build_api_wrapper", _boom) + + api_router = APIRouter() + add_command_map(MagicMock(spec=CommandRunner), api_router) + + assert plugins_api_router.routes == [] diff --git a/openbb_platform/core/tests/app/static/package_builder/test_docstring_generator_helpers.py b/openbb_platform/core/tests/app/static/package_builder/test_docstring_generator_helpers.py index c12b5288ed8..945bd4acad3 100644 --- a/openbb_platform/core/tests/app/static/package_builder/test_docstring_generator_helpers.py +++ b/openbb_platform/core/tests/app/static/package_builder/test_docstring_generator_helpers.py @@ -1644,3 +1644,56 @@ def test_generate_no_model_returns_str_type_name_and_any_fallback(monkeypatch): model_name=None, ) assert "Any" in out2 + + +def test_get_field_type_union_renders_forward_ref_by_name(): + """A ``ForwardRef`` inside a union renders as its target name.""" + from typing import ForwardRef + + out = DocstringGenerator.get_field_type( + list | ForwardRef("DataFrame"), is_required=True + ) + assert "DataFrame" in out + assert "ForwardRef" not in out + + +def test_generate_non_model_strips_parameters_section_for_dependency_params( + monkeypatch, +): + """A dependency-backed param drops the hand-written ``Parameters`` section.""" + from fastapi import Depends + + from openbb_core.app.static.package_builder import docstring_generator as dg + + class _SS: + class _PS: + docstring_sections = ["description", "parameters"] + docstring_max_length = None + + python_settings = _PS() + + class _Svc: + system_settings = _SS() + + monkeypatch.setattr(dg, "SystemService", _Svc) + + def _dep() -> dict: + return {} + + def _func(principal: Annotated[dict, Depends(_dep)]): + """Do a thing. + + Parameters + ---------- + principal : dict + The resolved principal. + """ + + out = DocstringGenerator.generate( + path="/x/y", + func=_func, + formatted_params=OrderedDict(), + model_name=None, + ) + assert "Do a thing." in out + assert "The resolved principal." not in out diff --git a/openbb_platform/core/tests/app/static/package_builder/test_method_definition_helpers.py b/openbb_platform/core/tests/app/static/package_builder/test_method_definition_helpers.py index 7e1b0bbafa6..1dc9d7210e7 100644 --- a/openbb_platform/core/tests/app/static/package_builder/test_method_definition_helpers.py +++ b/openbb_platform/core/tests/app/static/package_builder/test_method_definition_helpers.py @@ -1651,3 +1651,98 @@ def test_build_command_method_body_raw_query_params_fallback(monkeypatch): code = MethodDefinition.build_command_method_body("/x/y", endpoint) assert '"symbol": symbol' in code + + +def test_surfaced_param_spec_takes_description_from_metadata(): + from fastapi import Header + + param = Parameter( + "x_api_key", + Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[str, Header(description="The API key.")], + ) + annotation, _default = MethodDefinition._surfaced_param_spec(param) + field = annotation.__metadata__[0] + assert isinstance(field, OpenBBField) + assert field.description == "The API key." + + +def test_collect_dependency_calls_surfaces_unsafe_but_resolvable_dependency(): + """An unsafe dependency that is surfaceable is called with surfaced arguments.""" + from fastapi import Depends, Header + + def get_principal(x_api_key: Annotated[str, Header()]) -> dict: ... + + parameter_map = { + "principal": Parameter( + "principal", + Parameter.POSITIONAL_OR_KEYWORD, + annotation=Annotated[dict, Depends(get_principal)], + ) + } + + calls, names = MethodDefinition._collect_dependency_calls("/x/y", parameter_map) + assert calls == [" principal = get_principal(x_api_key=x_api_key)"] + assert names == {"principal"} + + +def test_build_command_method_promotes_surfaced_dependency_params(monkeypatch): + """Parameters surfaced from a dependency land on the generated signature.""" + from fastapi import Depends, Header + + monkeypatch.setattr( + MethodDefinition, "is_deprecated_function", staticmethod(lambda p: False) + ) + + def get_principal(x_api_key: Annotated[str, Header()]) -> dict: ... + + def endpoint(principal: Annotated[dict, Depends(get_principal)]) -> dict: + return principal + + code = MethodDefinition.build_command_method("/x/y", endpoint) + assert "x_api_key" in code + assert "principal = get_principal(x_api_key=x_api_key)" in code + + +def test_build_command_method_falls_back_to_response_model(monkeypatch): + """With no return annotation the passed ``response_model`` becomes the return type.""" + from openbb_core.app.model.obbject import OBBject + + monkeypatch.setattr( + MethodDefinition, "is_deprecated_function", staticmethod(lambda p: False) + ) + + def endpoint(symbol: str = "AAPL"): + return symbol + + code = MethodDefinition.build_command_method( + "/x/y", endpoint, response_model=OBBject + ) + assert "-> OBBject" in code + + +def test_format_params_expands_query_params_model_field_types(): + """Unpacking a bare ``QueryParams`` param expands ``TYPE_EXPANSION`` fields and + leaves unexpanded fields at their declared type.""" + from typing import get_args + + from openbb_core.provider.abstract.query_params import QueryParams + + class _QP(QueryParams): + data: list = [] + symbol: str = "AAPL" + + parameter_map = { + "params": Parameter("params", Parameter.POSITIONAL_OR_KEYWORD, annotation=_QP) + } + + out = MethodDefinition.format_params("/x/y", parameter_map) + + # ``data`` is in TYPE_EXPANSION -> the TypeVar constraints are inlined. + data_type = get_args(out["data"].annotation)[0] + rendered = str(data_type) + assert "DataFrame" in rendered + assert "dict" in rendered + + # ``symbol`` is not in TYPE_EXPANSION -> the declared type is kept as-is. + assert get_args(out["symbol"].annotation)[0] is str diff --git a/openbb_platform/core/tests/app/static/package_builder/test_path_handler.py b/openbb_platform/core/tests/app/static/package_builder/test_path_handler.py index 02e9d137f67..a5a64c7e5a6 100644 --- a/openbb_platform/core/tests/app/static/package_builder/test_path_handler.py +++ b/openbb_platform/core/tests/app/static/package_builder/test_path_handler.py @@ -122,3 +122,44 @@ def test_get_router_description_returns_description(monkeypatch): ) result = PathHandler.get_router_description("/equity") assert result == "My router description" + + +def test_build_route_map_skips_route_without_path(monkeypatch): + """A resolved route that carries no ``path`` is skipped.""" + monkeypatch.setattr( + "openbb_core.app.static.package_builder.path_handler.RouterLoader.from_extensions", + MagicMock(), + ) + monkeypatch.setattr( + "openbb_core.app.static.package_builder.path_handler.iter_api_routes", + lambda _router: iter([MagicMock(spec=[])]), + ) + assert PathHandler.build_route_map() == {} + + +def test_build_route_map_skips_route_excluded_from_schema(monkeypatch): + """A route flagged ``include_in_schema=False`` is left out of the route map.""" + from fastapi import APIRouter as FastAPIRouter + + async def hidden_endpoint() -> None: + pass + + async def visible_endpoint() -> None: + pass + + inner = FastAPIRouter() + inner.add_api_route( + "/hidden", hidden_endpoint, methods=["GET"], include_in_schema=False + ) + inner.add_api_route("/visible", visible_endpoint, methods=["GET"]) + + r = Router() + r._api_router = inner + monkeypatch.setattr( + "openbb_core.app.static.package_builder.path_handler.RouterLoader.from_extensions", + lambda: r, + ) + + route_map = PathHandler.build_route_map() + assert "/visible" in route_map + assert "/hidden" not in route_map diff --git a/openbb_platform/core/tests/app/static/test_import_definition.py b/openbb_platform/core/tests/app/static/test_import_definition.py index 20ef835f6bb..211722839c9 100644 --- a/openbb_platform/core/tests/app/static/test_import_definition.py +++ b/openbb_platform/core/tests/app/static/test_import_definition.py @@ -234,10 +234,6 @@ def test_unwrap_generic_args_swallows_get_args_exception(monkeypatch): def test_unwrap_generic_args_handles_annotated_alias(): """Lines 76-79: ``_AnnotatedAlias`` branch pushes ``__origin__`` on stack.""" out = import_def_module._unwrap_generic_args(Annotated[int, "tag"]) - # Origin (``int``) gets pushed and processed by the next loop iteration. - # ``int`` itself isn't a generic, so it lands in ``out`` only if it appears - # as a top-level type within get_args of the inner — which it doesn't here, - # so we just confirm the function returns cleanly without raising. assert isinstance(out, list) @@ -280,3 +276,38 @@ def test_get_function_hint_type_list_extracts_depends_from_annotated_param(): out = ImportDefinition.get_function_hint_type_list(route) assert _dep in out + + +def test_get_function_hint_type_list_falls_back_to_response_model(): + """An endpoint with no return annotation falls back to ``route.response_model``.""" + from datetime import datetime + + async def _endpoint(x: int): + return x + + route = SimpleNamespace( + endpoint=_endpoint, + openapi_extra={}, + response_model=datetime, + ) + + out = ImportDefinition.get_function_hint_type_list(route) + assert datetime in out + + +def test_get_function_hint_type_list_event_stream_returns_obbstream(): + """An ``text/event-stream`` response class forces an ``OBBStream`` return type.""" + from openbb_core.app.model.stream import OBBStream + + async def _endpoint(x: int) -> int: + return x + + route = SimpleNamespace( + endpoint=_endpoint, + openapi_extra={}, + response_model=int, + response_class=SimpleNamespace(media_type="text/event-stream"), + ) + + out = ImportDefinition.get_function_hint_type_list(route) + assert OBBStream in out diff --git a/openbb_platform/core/tests/app/test_platform_router.py b/openbb_platform/core/tests/app/test_platform_router.py index 82235aa9def..eb75f8135c2 100644 --- a/openbb_platform/core/tests/app/test_platform_router.py +++ b/openbb_platform/core/tests/app/test_platform_router.py @@ -415,3 +415,23 @@ def test_router_loader_from_extensions_loaderror(monkeypatch): pass finally: RouterLoader.from_extensions.cache_clear() + + +def test_command_with_stream_return_type(): + """Test an ``OBBStream`` return annotation drops the response model and + advertises an ``text/event-stream`` 200 response.""" + from openbb_core.app.model.stream import OBBStream + + stream_router = Router() + + @stream_router.command + async def stream_function() -> OBBStream: + """Stream something.""" + + route = stream_router.api_router.routes[-1] + assert route.path == "/stream_function" + assert route.response_model is None + assert route.responses[200] == { + "description": "Event stream", + "content": {"text/event-stream": {"schema": {"type": "string"}}}, + } diff --git a/openbb_platform/core/tests/app/test_route_iter.py b/openbb_platform/core/tests/app/test_route_iter.py new file mode 100644 index 00000000000..24d01942e45 --- /dev/null +++ b/openbb_platform/core/tests/app/test_route_iter.py @@ -0,0 +1,26 @@ +"""Tests for the ``route_iter`` helpers.""" + +from fastapi import APIRouter + +from openbb_core.app import route_iter +from openbb_core.app.route_iter import iter_api_routes, iter_included_routers + + +def test_iter_api_routes_object_without_routes(): + """An object that exposes no ``routes`` attribute yields nothing.""" + assert list(iter_api_routes(object())) == [] + + +def test_iter_included_routers_object_without_routes(): + """An object that exposes no ``routes`` attribute yields nothing.""" + assert list(iter_included_routers(object())) == [] + + +def test_iter_included_routers_without_included_router_support(monkeypatch): + """Without ``_IncludedRouter`` support there is nothing to walk into.""" + inner = APIRouter() + outer = APIRouter() + outer.include_router(inner, prefix="/inner") + + monkeypatch.setattr(route_iter, "_IncludedRouter", None) + assert list(iter_included_routers(outer)) == [] diff --git a/openbb_platform/core/tests/provider/test_registry_map.py b/openbb_platform/core/tests/provider/test_registry_map.py index aa74a55cf8e..c6688005bdd 100644 --- a/openbb_platform/core/tests/provider/test_registry_map.py +++ b/openbb_platform/core/tests/provider/test_registry_map.py @@ -7,6 +7,14 @@ providers built in ``tests/conftest.py``. The suite intentionally does not depend on any installed provider extension. """ +from collections.abc import ( + AsyncGenerator, + AsyncIterable, + AsyncIterator, + Iterable, + Iterator, +) +from types import SimpleNamespace from typing import Any import pytest @@ -188,6 +196,27 @@ def test_results_type_is_none_for_single_data_return( assert record["results_type"] is None +@pytest.mark.parametrize( + "return_type", + [ + AsyncIterator[Data], + AsyncIterable[Data], + AsyncGenerator[Data, None], + Iterator[Data], + Iterable[Data], + ], +) +def test_results_type_normalizes_streaming_containers(return_type: Any): + """A fetcher returning a streaming container normalizes to ``list``.""" + fetcher = SimpleNamespace(return_type=return_type) + assert RegistryMap._get_results_type(fetcher) is list + + +def test_results_type_is_none_when_fetcher_has_no_return_type(): + """A fetcher with no ``return_type`` has no results container.""" + assert RegistryMap._get_results_type(SimpleNamespace()) is None + + def test_validate_rejects_non_data_subclass(): """``_validate`` raises ``ValueError`` for a non-``Data`` data type.""" diff --git a/openbb_platform/core/tests/provider/utils/test_helpers.py b/openbb_platform/core/tests/provider/utils/test_helpers.py index d9af0267773..fb921fbcbe1 100644 --- a/openbb_platform/core/tests/provider/utils/test_helpers.py +++ b/openbb_platform/core/tests/provider/utils/test_helpers.py @@ -2,6 +2,7 @@ import asyncio import datetime as dt +from pathlib import Path import pytest @@ -654,6 +655,35 @@ def test_combine_certificates_returns_existing_combined_file(tmp_path): assert combine_certificates(str(cert)) == str(combined) +def test_combine_certificates_cleanup_tolerates_missing_file(tmp_path, monkeypatch): + """The registered atexit cleanup must not raise if the file is already gone.""" + import atexit + + import certifi + + from openbb_core.provider.utils import helpers + + cert = tmp_path / "ca.pem" + cert.write_text("CERT") + bundle = tmp_path / "ca-bundle.pem" + bundle.write_text("BUNDLE") + monkeypatch.setattr(certifi, "where", lambda: str(bundle)) + + registered: list = [] + monkeypatch.setattr( + atexit, "register", lambda func, *a, **kw: registered.append((func, a, kw)) + ) + + out = Path(helpers.combine_certificates(str(cert))) + assert out.exists() + assert registered + + # Something else removes it first - the cleanup must still be a no-op. + out.unlink() + for func, args, kwargs in registered: + func(*args, **kwargs) + + def test_make_request_timeout_from_preferences(monkeypatch): captured = {}