mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-10-27 20:42:26 +00:00
* stash some changes * add more robust testing * mypy * point PR at V5 * introduce spec file * codespell * test fix * fix workflow environment setup * fix workflow environment setup * fix workflow environment setup * add pyyaml to dependencies * split lint jobs * fix workflow environment setup * fix workflow environment setup * workflow env setup * workflow env setup * clean up code comments * add auth hook entrypoints * codespell * add codegen feature * codespell * move _unpack into dispatchers for consistency with codegen packages * surface nested models in the response * fix missing coverage in CI * socrata updates * test fix * detect plotly output * add --include and --exclude flags from generate-extension command * cap test matrix at python 3.14 * no useless comments * platform controller command description split * merge URL overloads from path params * exclude none and unset from model dump --------- Co-authored-by: deeleeramone <> Co-authored-by: Copilot <copilot@github.com>
528 lines
16 KiB
Python
528 lines
16 KiB
Python
"""Tests for openbb_cli.codegen.test_gen — per-provider unit-test emission."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
from dataclasses import dataclass, field
|
|
|
|
from openbb_cli.codegen import test_gen as tg
|
|
|
|
|
|
@dataclass
|
|
class _Fetcher:
|
|
module_name: str
|
|
fetcher_class: str
|
|
model_name: str = ""
|
|
credentials_used: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
|
|
|
|
def _spec_with_choices_and_default():
|
|
return {
|
|
"equity.search": {
|
|
"parameters": [
|
|
{
|
|
"name": "scope",
|
|
"type": "string",
|
|
"required": True,
|
|
"choices": ["all", "active"],
|
|
},
|
|
{
|
|
"name": "format",
|
|
"type": "string",
|
|
"required": True,
|
|
"default": "json",
|
|
"choices": ["json", "xml"],
|
|
},
|
|
],
|
|
},
|
|
}
|
|
|
|
|
|
# --- _spec_supplied_value ---
|
|
|
|
|
|
def test_spec_supplied_value_prefers_example_over_everything():
|
|
assert (
|
|
tg._spec_supplied_value(
|
|
{
|
|
"example": "AAPL",
|
|
"default": "MSFT",
|
|
"choices": ["GOOG", "AMZN"],
|
|
}
|
|
)
|
|
== "AAPL"
|
|
)
|
|
|
|
|
|
def test_spec_supplied_value_prefers_default_over_choices():
|
|
assert (
|
|
tg._spec_supplied_value({"default": "json", "choices": ["xml", "json"]})
|
|
== "json"
|
|
)
|
|
|
|
|
|
def test_spec_supplied_value_falls_back_to_first_choice():
|
|
assert tg._spec_supplied_value({"choices": ["all", "active"]}) == "all"
|
|
|
|
|
|
def test_spec_supplied_value_returns_none_when_no_default_or_choices():
|
|
assert tg._spec_supplied_value({"type": "string"}) is None
|
|
assert tg._spec_supplied_value({}) is None
|
|
|
|
|
|
def test_spec_supplied_value_treats_empty_choices_as_no_choices():
|
|
assert tg._spec_supplied_value({"choices": []}) is None
|
|
|
|
|
|
def test_normalize_to_module_matches_fetcher_module_naming_with_spaces():
|
|
"""Dotted names with spaces collapse the same way fetcher module names do."""
|
|
assert tg._normalize_to_module("soma.agency.wam.agency debts.asof") == (
|
|
"soma_agency_wam_agency_debts_asof"
|
|
)
|
|
|
|
|
|
def test_normalize_to_module_handles_leading_digit_and_empty():
|
|
assert tg._normalize_to_module("3legged.path") == "_3legged_path"
|
|
assert tg._normalize_to_module("___") == "command"
|
|
|
|
|
|
# --- _derive_test_params ---
|
|
|
|
|
|
def test_derive_test_params_returns_empty_when_no_required_params():
|
|
cmd = {"parameters": [{"name": "x", "type": "string"}]}
|
|
assert tg._derive_test_params(cmd, "fmp") == {}
|
|
|
|
|
|
def test_derive_test_params_includes_only_required_with_known_value():
|
|
cmd = {
|
|
"parameters": [
|
|
{
|
|
"name": "scope",
|
|
"type": "string",
|
|
"required": True,
|
|
"choices": ["all"],
|
|
},
|
|
{"name": "limit", "type": "integer", "default": 10},
|
|
]
|
|
}
|
|
out = tg._derive_test_params(cmd, "fmp")
|
|
assert out == {"scope": "all"}
|
|
|
|
|
|
def test_derive_test_params_returns_none_when_required_param_has_no_supplied_value():
|
|
cmd = {
|
|
"parameters": [
|
|
{"name": "cusip", "type": "string", "required": True},
|
|
]
|
|
}
|
|
assert tg._derive_test_params(cmd, "fmp") is None
|
|
|
|
|
|
def test_derive_test_params_skips_provider_discriminator():
|
|
cmd = {
|
|
"parameters": [
|
|
{"name": "provider", "type": "string", "required": True},
|
|
{
|
|
"name": "scope",
|
|
"type": "string",
|
|
"required": True,
|
|
"choices": ["all"],
|
|
},
|
|
]
|
|
}
|
|
assert tg._derive_test_params(cmd, "fmp") == {"scope": "all"}
|
|
|
|
|
|
def test_derive_test_params_skips_unnamed_or_non_dict_entries():
|
|
cmd = {
|
|
"parameters": [
|
|
"not a dict",
|
|
{"type": "string", "required": True},
|
|
{
|
|
"name": "scope",
|
|
"type": "string",
|
|
"required": True,
|
|
"choices": ["all"],
|
|
},
|
|
]
|
|
}
|
|
assert tg._derive_test_params(cmd, "fmp") == {"scope": "all"}
|
|
|
|
|
|
def test_derive_test_params_skips_param_outside_provider_whitelist():
|
|
cmd = {
|
|
"parameters": [
|
|
{
|
|
"name": "use_cache",
|
|
"type": "boolean",
|
|
"required": True,
|
|
"providers": ["cboe"],
|
|
},
|
|
]
|
|
}
|
|
# ``use_cache`` is cboe-only — fmp's QueryParams class doesn't have it,
|
|
# so it's skipped instead of failing the whole test.
|
|
assert tg._derive_test_params(cmd, "fmp") == {}
|
|
|
|
|
|
def test_derive_test_params_skips_optional_choices_to_avoid_over_constraining():
|
|
"""Optional ``choices`` over-constrain queries (rp_results_search empty
|
|
when method=multiple+term=overnight added). Better to omit and let the
|
|
upstream choose its own defaults."""
|
|
cmd = {
|
|
"parameters": [
|
|
{
|
|
"name": "scope",
|
|
"type": "string",
|
|
"required": True,
|
|
"choices": ["all"],
|
|
},
|
|
{
|
|
"name": "operationTypes",
|
|
"type": "string",
|
|
"required": False,
|
|
"choices": ["Repo", "Reverse Repo"],
|
|
},
|
|
]
|
|
}
|
|
out = tg._derive_test_params(cmd, "fmp")
|
|
assert out == {"scope": "all"}
|
|
assert "operationTypes" not in out
|
|
|
|
|
|
def test_derive_test_params_includes_optional_date_params_with_current_date():
|
|
"""Search endpoints typically need a date range to return data."""
|
|
cmd = {
|
|
"parameters": [
|
|
{
|
|
"name": "scope",
|
|
"type": "string",
|
|
"required": True,
|
|
"choices": ["all"],
|
|
},
|
|
{"name": "startDate", "type": "string", "required": False},
|
|
{"name": "endDate", "type": "string", "required": False},
|
|
]
|
|
}
|
|
out = tg._derive_test_params(cmd, "fmp")
|
|
from datetime import date, timedelta
|
|
|
|
assert out["scope"] == "all"
|
|
assert out["startDate"] == str(date.today() - timedelta(days=30))
|
|
assert out["endDate"] == str(date.today())
|
|
|
|
|
|
def test_derive_test_params_skips_optional_param_without_date_shape_or_required():
|
|
cmd = {
|
|
"parameters": [
|
|
{
|
|
"name": "scope",
|
|
"type": "string",
|
|
"required": True,
|
|
"choices": ["all"],
|
|
},
|
|
{"name": "extra", "type": "string", "required": False},
|
|
{
|
|
"name": "cusip",
|
|
"type": "string",
|
|
"required": False,
|
|
"example": "01234ABCD",
|
|
},
|
|
]
|
|
}
|
|
out = tg._derive_test_params(cmd, "fmp")
|
|
assert out == {"scope": "all"}
|
|
|
|
|
|
def test_is_date_param_recognizes_canonical_names():
|
|
assert tg._is_date_param("startDate")
|
|
assert tg._is_date_param("endDate")
|
|
assert tg._is_date_param("date")
|
|
assert tg._is_date_param("fromDate")
|
|
assert tg._is_date_param("toDate")
|
|
assert tg._is_date_param("from")
|
|
assert tg._is_date_param("to")
|
|
assert tg._is_date_param("operationDate") # contains "date"
|
|
assert not tg._is_date_param("symbol")
|
|
assert not tg._is_date_param("limit")
|
|
|
|
|
|
# --- _refresh_date_shape ---
|
|
|
|
|
|
def test_refresh_date_shape_replaces_yyyy_mm_dd_example_with_today():
|
|
out = tg._refresh_date_shape("endDate", "2021-01-01")
|
|
from datetime import date
|
|
|
|
assert out == str(date.today())
|
|
|
|
|
|
def test_refresh_date_shape_uses_thirty_days_ago_for_start_date():
|
|
out = tg._refresh_date_shape("startDate", "2021-01-01")
|
|
from datetime import date, timedelta
|
|
|
|
assert out == str(date.today() - timedelta(days=30))
|
|
|
|
|
|
def test_refresh_date_shape_treats_from_as_start_alias():
|
|
out = tg._refresh_date_shape("from", "2021-01-01")
|
|
from datetime import date, timedelta
|
|
|
|
assert out == str(date.today() - timedelta(days=30))
|
|
|
|
|
|
def test_refresh_date_shape_handles_iso_timestamps():
|
|
"""ISO-8601 timestamps refresh to today's UTC timestamp."""
|
|
out = tg._refresh_date_shape("toDateTime", "2021-01-01T00:00:00Z")
|
|
assert out.endswith("Z")
|
|
assert "T" in out
|
|
# It's an actual parseable iso datetime
|
|
from datetime import datetime
|
|
|
|
parsed = datetime.fromisoformat(out.replace("Z", "+00:00"))
|
|
assert parsed.year >= 2024
|
|
|
|
|
|
def test_refresh_date_shape_passes_through_non_date_strings():
|
|
assert tg._refresh_date_shape("symbol", "AAPL") == "AAPL"
|
|
|
|
|
|
def test_refresh_date_shape_passes_through_non_strings():
|
|
assert tg._refresh_date_shape("limit", 5) == 5
|
|
assert tg._refresh_date_shape("flag", True) is True
|
|
|
|
|
|
def test_spec_supplied_value_returns_none_for_open_ended_param():
|
|
"""Param with no example, default, or choices yields None — caller skips test."""
|
|
assert tg._spec_supplied_value({"name": "cusip", "type": "string"}) is None
|
|
|
|
|
|
# --- generate_provider_tests ---
|
|
|
|
|
|
def test_generate_provider_tests_returns_none_when_no_fetchers():
|
|
out = tg.generate_provider_tests(
|
|
package_name="openbb_x",
|
|
provider_name="x",
|
|
fetchers=[],
|
|
commands_by_dotted={},
|
|
)
|
|
assert out is None
|
|
|
|
|
|
def test_generate_provider_tests_emits_runnable_module():
|
|
fetchers = [
|
|
_Fetcher(module_name="equity_search", fetcher_class="EquitySearchFetcher"),
|
|
]
|
|
out = tg.generate_provider_tests(
|
|
package_name="openbb_x",
|
|
provider_name="x",
|
|
fetchers=fetchers,
|
|
commands_by_dotted=_spec_with_choices_and_default(),
|
|
)
|
|
assert out is not None
|
|
assert out.module_name == "test_x_fetchers"
|
|
src = out.source
|
|
ast.parse(src)
|
|
assert "import pytest" in src
|
|
assert (
|
|
"from openbb_x.providers.x.models.equity_search import EquitySearchFetcher"
|
|
) in src
|
|
assert "test_credentials = UserService()" in src
|
|
assert "@pytest.fixture" in src
|
|
assert "def vcr_config():" in src
|
|
assert "@pytest.mark.record_http" in src
|
|
assert "def test_equity_search_fetcher(credentials=test_credentials):" in src
|
|
assert "fetcher = EquitySearchFetcher()" in src
|
|
assert "result = fetcher.test(params, credentials)" in src
|
|
assert "assert result is None" in src
|
|
# Required params: scope (choices[0]='all') + format (default='json').
|
|
assert "'scope': 'all'" in src
|
|
assert "'format': 'json'" in src
|
|
|
|
|
|
def test_generate_provider_tests_skips_fetchers_with_unsuppliable_required_params():
|
|
fetchers = [
|
|
_Fetcher(module_name="equity_search", fetcher_class="EquitySearchFetcher"),
|
|
_Fetcher(module_name="x_y", fetcher_class="XYFetcher"),
|
|
]
|
|
cmds = {
|
|
"equity.search": _spec_with_choices_and_default()["equity.search"],
|
|
# XY requires a free-form ``cusip`` -- can't derive a valid value
|
|
"x.y": {
|
|
"parameters": [
|
|
{"name": "cusip", "type": "string", "required": True},
|
|
]
|
|
},
|
|
}
|
|
out = tg.generate_provider_tests(
|
|
package_name="openbb_x",
|
|
provider_name="x",
|
|
fetchers=fetchers,
|
|
commands_by_dotted=cmds,
|
|
)
|
|
assert out is not None
|
|
assert "EquitySearchFetcher()" in out.source
|
|
assert "XYFetcher" not in out.source
|
|
|
|
|
|
def test_generate_provider_tests_returns_none_when_every_fetcher_unconfident():
|
|
fetchers = [
|
|
_Fetcher(module_name="x_y", fetcher_class="XYFetcher"),
|
|
_Fetcher(module_name="a_b", fetcher_class="ABFetcher"),
|
|
]
|
|
cmds = {
|
|
"x.y": {"parameters": [{"name": "cusip", "type": "string", "required": True}]},
|
|
"a.b": {"parameters": [{"name": "raw_id", "type": "string", "required": True}]},
|
|
}
|
|
assert (
|
|
tg.generate_provider_tests(
|
|
package_name="openbb_x",
|
|
provider_name="x",
|
|
fetchers=fetchers,
|
|
commands_by_dotted=cmds,
|
|
)
|
|
is None
|
|
)
|
|
|
|
|
|
def test_generate_provider_tests_handles_unknown_command_dotted_path():
|
|
"""Fetcher whose module name doesn't match any dotted command -> empty params."""
|
|
fetchers = [_Fetcher(module_name="orphan", fetcher_class="OrphanFetcher")]
|
|
out = tg.generate_provider_tests(
|
|
package_name="openbb_x",
|
|
provider_name="x",
|
|
fetchers=fetchers,
|
|
commands_by_dotted={"some.other.thing": {"parameters": []}},
|
|
)
|
|
assert out is not None
|
|
assert "params = {}" in out.source
|
|
|
|
|
|
# --- vcr_config redactions ---
|
|
|
|
|
|
def test_generate_provider_tests_emits_query_credential_with_mock_value():
|
|
fetchers = [
|
|
_Fetcher(
|
|
module_name="equity_search",
|
|
fetcher_class="EquitySearchFetcher",
|
|
credentials_used={"api_key": {"name": "apikey", "in": "query"}},
|
|
),
|
|
]
|
|
out = tg.generate_provider_tests(
|
|
package_name="openbb_x",
|
|
provider_name="x",
|
|
fetchers=fetchers,
|
|
commands_by_dotted=_spec_with_choices_and_default(),
|
|
)
|
|
assert out is not None
|
|
assert "filter_query_parameters" in out.source
|
|
assert "('apikey', 'MOCK_API_KEY')" in out.source
|
|
|
|
|
|
def test_generate_provider_tests_emits_header_credential_with_bearer_mock():
|
|
fetchers = [
|
|
_Fetcher(
|
|
module_name="equity_search",
|
|
fetcher_class="EquitySearchFetcher",
|
|
credentials_used={
|
|
"authorization": {"name": "Authorization", "in": "header"}
|
|
},
|
|
),
|
|
]
|
|
out = tg.generate_provider_tests(
|
|
package_name="openbb_x",
|
|
provider_name="x",
|
|
fetchers=fetchers,
|
|
commands_by_dotted=_spec_with_choices_and_default(),
|
|
)
|
|
assert out is not None
|
|
assert "('Authorization', 'Bearer MOCK_TOKEN')" in out.source
|
|
|
|
|
|
def test_generate_provider_tests_includes_standard_redaction_baseline():
|
|
"""Even without provider-declared credentials, the baseline redactions ship."""
|
|
fetchers = [
|
|
_Fetcher(module_name="equity_search", fetcher_class="EquitySearchFetcher")
|
|
]
|
|
out = tg.generate_provider_tests(
|
|
package_name="openbb_x",
|
|
provider_name="x",
|
|
fetchers=fetchers,
|
|
commands_by_dotted=_spec_with_choices_and_default(),
|
|
)
|
|
assert out is not None
|
|
assert "('User-Agent', None)" in out.source
|
|
for entry in (
|
|
"('api_key', 'MOCK_API_KEY')",
|
|
"('client_secret', 'MOCK_CLIENT_SECRET')",
|
|
"('access_token', 'MOCK_TOKEN')",
|
|
"('private_key', 'MOCK_SECRET')",
|
|
):
|
|
assert entry in out.source
|
|
|
|
|
|
# --- _mock_value_for branches ---
|
|
|
|
|
|
def test_mock_value_for_picks_secret_when_name_contains_secret():
|
|
assert tg._mock_value_for("client_secret") == "MOCK_SECRET"
|
|
assert tg._mock_value_for("private_key_secret") == "MOCK_SECRET"
|
|
|
|
|
|
def test_mock_value_for_picks_token_when_name_contains_token():
|
|
assert tg._mock_value_for("auth_token") == "MOCK_TOKEN"
|
|
assert tg._mock_value_for("X-Auth-Token") == "MOCK_TOKEN"
|
|
|
|
|
|
def test_mock_value_for_picks_client_id_for_id_shaped_names():
|
|
assert tg._mock_value_for("client_id") == "MOCK_CLIENT_ID"
|
|
assert tg._mock_value_for("appid") == "MOCK_CLIENT_ID"
|
|
assert tg._mock_value_for("app_id") == "MOCK_CLIENT_ID"
|
|
|
|
|
|
def test_mock_value_for_default_falls_back_to_api_key():
|
|
assert tg._mock_value_for("apikey") == "MOCK_API_KEY"
|
|
assert tg._mock_value_for("subscription_key") == "MOCK_API_KEY"
|
|
|
|
|
|
# --- _merge_redactions edge cases ---
|
|
|
|
|
|
def test_merge_redactions_skips_credentials_without_wire_name():
|
|
fetchers = [
|
|
_Fetcher(
|
|
module_name="x",
|
|
fetcher_class="XFetcher",
|
|
credentials_used={"weird": {"in": "query"}},
|
|
),
|
|
]
|
|
headers, queries = tg._merge_redactions(fetchers)
|
|
assert all(name for name, _ in headers)
|
|
assert all(name for name, _ in queries)
|
|
|
|
|
|
def test_merge_redactions_picks_up_provider_credential_outside_baseline():
|
|
fetchers = [
|
|
_Fetcher(
|
|
module_name="x",
|
|
fetcher_class="XFetcher",
|
|
credentials_used={"weird_creds": {"name": "X-Weird-Creds", "in": "header"}},
|
|
),
|
|
]
|
|
headers, _ = tg._merge_redactions(fetchers)
|
|
assert ("X-Weird-Creds", "MOCK_API_KEY") in headers
|
|
|
|
|
|
# --- _snake ---
|
|
|
|
|
|
def test_snake_handles_multi_word_camelcase():
|
|
assert (
|
|
tg._snake("EquityPriceHistoricalFetcher") == "equity_price_historical_fetcher"
|
|
)
|
|
assert tg._snake("XYZFetcher") == "xyzfetcher"
|
|
assert tg._snake("simple") == "simple"
|