|
3 | 3 | import numpy as np |
4 | 4 | import pandas as pd |
5 | 5 | import pytest |
| 6 | +from openbb_core.app.model.abstract.error import OpenBBError |
6 | 7 | from openbb_core.app.static.utils.filters import filter_inputs |
7 | 8 | from openbb_core.provider.abstract.data import Data |
8 | 9 |
|
@@ -67,3 +68,160 @@ def test_filter_inputs( |
67 | 68 | assert isinstance( |
68 | 69 | result["data"], Data |
69 | 70 | ), f"The 'data' key should be of type {Data.__name__}" |
| 71 | + |
| 72 | + |
| 73 | +# --- Choices validation tests --- |
| 74 | +# These tests cover the fix for a silent data corruption bug: |
| 75 | +# |
| 76 | +# EXACT BUG SCENARIO (obb.economy.balance_of_payments): |
| 77 | +# - ECB's `frequency` default is "monthly"; OECD only supports "annual"/"quarterly". |
| 78 | +# - `openbb-build` merges provider schemas into a single ExtraParams dataclass whose |
| 79 | +# `frequency` field has type Union[Literal['monthly','quarterly'], Literal['annual', |
| 80 | +# 'quarterly']] and default="monthly" (taken from ECB, first alphabetically). |
| 81 | +# - "mont" was correctly rejected: it satisfies neither Literal, so ExtraParams |
| 82 | +# instantiation raises a ValidationError before any provider logic runs. |
| 83 | +# - "monthly" silently passed: it satisfies ECB's Literal, so ExtraParams accepted |
| 84 | +# it. `filter_extra_params` then saw frequency="monthly" == merged default="monthly" |
| 85 | +# and DROPPED the parameter entirely. OECD's transform_query received no `frequency` |
| 86 | +# kwarg and fell back to its own pydantic default "quarterly", returning quarterly |
| 87 | +# data with zero error or warning. |
| 88 | +# |
| 89 | +# The fix has two components: |
| 90 | +# 1. `_create_field` in `provider_interface.py` now auto-derives per-provider choices |
| 91 | +# from Literal type annotations. A provider field declared as |
| 92 | +# `frequency: Literal["annual", "quarterly"]` causes openbb-build to emit |
| 93 | +# `"frequency": {"oecd": {"choices": ["annual", "quarterly"]}}` into the info dict |
| 94 | +# of the generated package file — no manual `__json_schema_extra__` entry needed. |
| 95 | +# 2. `filter_inputs` validates the raw user kwargs dict against those choices BEFORE |
| 96 | +# the merged ExtraParams dataclass is built, so the explicitly-passed "monthly" |
| 97 | +# is caught at the boundary regardless of what the merged default is. |
| 98 | + |
| 99 | +# Info dict WITH choices — mirrors the post-fix `economy.py` generated by openbb-build. |
| 100 | +# Choices are now auto-derived from the provider's Literal annotation: |
| 101 | +# `frequency: Literal["annual", "quarterly"]` → choices=["annual", "quarterly"] |
| 102 | +# No manual `__json_schema_extra__` entry is required. |
| 103 | +_BOP_INFO_WITH_CHOICES = { |
| 104 | + "frequency": { |
| 105 | + "oecd": {"multiple_items_allowed": False, "choices": ["annual", "quarterly"]}, |
| 106 | + } |
| 107 | +} |
| 108 | + |
| 109 | +# Info dict WITHOUT choices — represents the pre-fix state where Literal annotations |
| 110 | +# were NOT reflected in the info dict, so filter_inputs had nothing to validate against. |
| 111 | +_BOP_INFO_WITHOUT_CHOICES = { |
| 112 | + "frequency": { |
| 113 | + "oecd": {"multiple_items_allowed": False}, |
| 114 | + } |
| 115 | +} |
| 116 | + |
| 117 | +# Shorter alias used by the general-purpose tests below. |
| 118 | +_BOP_INFO = _BOP_INFO_WITH_CHOICES |
| 119 | + |
| 120 | + |
| 121 | +def _bop_kwargs(provider: str, **extra) -> dict: |
| 122 | + """Return a filter_inputs kwargs structure for the BOP endpoint.""" |
| 123 | + return { |
| 124 | + "provider_choices": {"provider": provider}, |
| 125 | + "standard_params": {}, |
| 126 | + "extra_params": {"frequency": "quarterly", **extra}, |
| 127 | + } |
| 128 | + |
| 129 | + |
| 130 | +def test_filter_inputs_choices_valid_value_passes(): |
| 131 | + """A value that is in the allowed choices must be accepted without error.""" |
| 132 | + kwargs = _bop_kwargs("oecd", frequency="annual") |
| 133 | + result = filter_inputs(info=_BOP_INFO, **kwargs) |
| 134 | + assert result["extra_params"]["frequency"] == "annual" |
| 135 | + |
| 136 | + |
| 137 | +def test_filter_inputs_choices_default_value_passes(): |
| 138 | + """The default value ('quarterly') must also be accepted.""" |
| 139 | + kwargs = _bop_kwargs("oecd") # frequency defaults to 'quarterly' |
| 140 | + result = filter_inputs(info=_BOP_INFO, **kwargs) |
| 141 | + assert result["extra_params"]["frequency"] == "quarterly" |
| 142 | + |
| 143 | + |
| 144 | +def test_filter_inputs_choices_invalid_value_raises(): |
| 145 | + """ |
| 146 | + Regression test: 'monthly' must be rejected for the 'oecd' provider. |
| 147 | +
|
| 148 | + Before the fix, `filter_extra_params` would silently drop `frequency="monthly"` |
| 149 | + because it equalled the merged ExtraParams default (taken from ECB). The OECD |
| 150 | + fetcher then used its own pydantic default 'quarterly', returning quarterly data |
| 151 | + with no error or warning. |
| 152 | +
|
| 153 | + After the fix, `filter_inputs` validates the value against the provider's allowed |
| 154 | + choices and raises an `OpenBBError` with a descriptive message. |
| 155 | + """ |
| 156 | + kwargs = _bop_kwargs("oecd", frequency="monthly") |
| 157 | + with pytest.raises(OpenBBError, match="Invalid value 'monthly' for 'frequency'"): |
| 158 | + filter_inputs(info=_BOP_INFO, **kwargs) |
| 159 | + |
| 160 | + |
| 161 | +def test_filter_inputs_choices_not_enforced_for_other_provider(): |
| 162 | + """ |
| 163 | + Choices defined for 'oecd' must NOT apply when a different provider is selected. |
| 164 | + ECB legitimately accepts 'monthly', so it must pass without error. |
| 165 | + """ |
| 166 | + kwargs = _bop_kwargs("ecb", frequency="monthly") |
| 167 | + result = filter_inputs(info=_BOP_INFO, **kwargs) |
| 168 | + assert result["extra_params"]["frequency"] == "monthly" |
| 169 | + |
| 170 | + |
| 171 | +def test_filter_inputs_choices_no_info_no_error(): |
| 172 | + """When no `info` dict is supplied, no choices validation occurs.""" |
| 173 | + kwargs = { |
| 174 | + "provider_choices": {"provider": "oecd"}, |
| 175 | + "extra_params": {"frequency": "monthly"}, |
| 176 | + } |
| 177 | + # Must not raise even though 'monthly' would be invalid for oecd with choices |
| 178 | + result = filter_inputs(**kwargs) |
| 179 | + assert result["extra_params"]["frequency"] == "monthly" |
| 180 | + |
| 181 | + |
| 182 | +# --- Negative tests: exact bug reproduction --- |
| 183 | +# These two tests mirror the discriminating condition that first exposed the bug: |
| 184 | +# "mont" → rejected (caught by ExtraParams Literal validation — was already working) |
| 185 | +# "monthly" → accepted (equalled the merged default → silently dropped → wrong data) |
| 186 | + |
| 187 | + |
| 188 | +def test_filter_inputs_pre_fix_monthly_passed_without_choices(): |
| 189 | + """ |
| 190 | + Pre-fix negative: without choices in the info dict, 'monthly' passes through |
| 191 | + filter_inputs unchallenged — exactly as it did before the fix. |
| 192 | +
|
| 193 | + Before the fix, Literal annotations on provider fields were not reflected in the |
| 194 | + info dict written by openbb-build, so filter_inputs had no choices to validate |
| 195 | + against. Once 'monthly' exits filter_inputs, filter_extra_params sees |
| 196 | + v == merged_default ("monthly") and silently drops the parameter, so the OECD |
| 197 | + model never validates it. |
| 198 | + """ |
| 199 | + kwargs = { |
| 200 | + "provider_choices": {"provider": "oecd"}, |
| 201 | + "standard_params": {}, |
| 202 | + "extra_params": {"frequency": "monthly"}, |
| 203 | + } |
| 204 | + # No choices → no rejection → 'monthly' passes through (pre-fix behaviour) |
| 205 | + result = filter_inputs(info=_BOP_INFO_WITHOUT_CHOICES, **kwargs) |
| 206 | + assert result["extra_params"]["frequency"] == "monthly" |
| 207 | + |
| 208 | + |
| 209 | +def test_filter_inputs_post_fix_monthly_rejected_with_choices(): |
| 210 | + """ |
| 211 | + Post-fix negative: with choices in the info dict, the same 'monthly' value |
| 212 | + that used to silently corrupt the result is now rejected with a clear error. |
| 213 | +
|
| 214 | + Choices are now auto-derived by openbb-build from the provider's Literal annotation |
| 215 | + (`frequency: Literal["annual", "quarterly"]`), so no manual __json_schema_extra__ |
| 216 | + entry is required. Together with the test above this pair proves: |
| 217 | + - 'mont' → already failed (Literal Union rejects it at ExtraParams build time) |
| 218 | + - 'monthly' → used to pass (satisfied ECB's Literal, equalled merged default) |
| 219 | + - 'monthly' → now fails (filter_inputs catches it via choices before ExtraParams) |
| 220 | + """ |
| 221 | + kwargs = { |
| 222 | + "provider_choices": {"provider": "oecd"}, |
| 223 | + "standard_params": {}, |
| 224 | + "extra_params": {"frequency": "monthly"}, |
| 225 | + } |
| 226 | + with pytest.raises(OpenBBError, match="Invalid value 'monthly' for 'frequency'"): |
| 227 | + filter_inputs(info=_BOP_INFO_WITH_CHOICES, **kwargs) |
0 commit comments