Skip to content

Commit dddc3b3

Browse files
authored
[BugFix] openbb-core: Fix Merged Choices Validation in Python Interface (#7404)
* add choices validation to filter_inputs * provider interface * provider interface test --------- Co-authored-by: deeleeramone <>
1 parent a6a82a6 commit dddc3b3

4 files changed

Lines changed: 268 additions & 0 deletions

File tree

openbb_platform/core/openbb_core/app/provider_interface.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,27 @@ def _create_field(
285285
+ ", ".join(providers) # type: ignore[arg-type]
286286
+ "."
287287
)
288+
289+
# Auto-derive choices from Literal annotation for provider-specific fields
290+
# when no explicit choices are already declared for this provider.
291+
# This makes Literal annotations equivalent to manually declared choices,
292+
# so providers only need to declare the type annotation.
293+
if provider_name and provider_name not in choices:
294+
_ann = annotation
295+
_origin = get_origin(_ann)
296+
# Unwrap Optional[Literal[...]] = Union[Literal[...], None]
297+
if _origin is Union:
298+
_inner = [a for a in get_args(_ann) if a is not type(None)]
299+
if len(_inner) == 1:
300+
_ann = _inner[0]
301+
_origin = get_origin(_ann)
302+
if _origin is Literal:
303+
_literal_args = list(get_args(_ann))
304+
if _literal_args:
305+
choices[provider_name] = {
306+
"choices": _literal_args,
307+
}
308+
288309
provider_field = (
289310
f"(provider: {provider_name})" if provider_name != "openbb" else ""
290311
)

openbb_platform/core/openbb_core/app/static/utils/filters.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from typing import Any
44

5+
from openbb_core.app.model.abstract.error import OpenBBError
56
from openbb_core.app.utils import check_single_item, convert_to_basemodel
67

78

@@ -49,6 +50,25 @@ def filter_inputs(
4950
f"{field} -> multiple items not allowed for '{provider}'",
5051
)
5152

53+
choices = (
54+
provider_properties.get("choices")
55+
if isinstance(provider_properties, dict)
56+
else None
57+
)
58+
if choices:
59+
items = (
60+
[s.strip() for s in new.split(",")]
61+
if isinstance(new, str) and "," in new
62+
else [new]
63+
)
64+
for item in items:
65+
if item not in choices:
66+
raise OpenBBError(
67+
f"Invalid value '{item}' for '{field}'"
68+
f" (provider: '{provider}')."
69+
f" Must be one of: {choices}"
70+
)
71+
5272
kwargs[p][field] = new
5373
break
5474
else:

openbb_platform/core/tests/app/static/test_filters.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import numpy as np
44
import pandas as pd
55
import pytest
6+
from openbb_core.app.model.abstract.error import OpenBBError
67
from openbb_core.app.static.utils.filters import filter_inputs
78
from openbb_core.provider.abstract.data import Data
89

@@ -67,3 +68,160 @@ def test_filter_inputs(
6768
assert isinstance(
6869
result["data"], Data
6970
), 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)

openbb_platform/core/tests/app/test_provider_interface.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@
22

33
# pylint: disable=redefined-outer-name
44

5+
from typing import Literal, Optional
6+
57
import pytest
68
from openbb_core.app.provider_interface import (
79
ProviderChoices,
810
ProviderInterface,
911
)
12+
from pydantic.fields import FieldInfo
1013

1114

1215
@pytest.fixture(scope="module")
@@ -78,3 +81,69 @@ def test_models(provider_interface):
7881
assert isinstance(models, list)
7982
assert len(models) > 0
8083
assert "EquityHistorical" in models
84+
85+
86+
# --- _create_field: Literal → choices auto-derivation ---
87+
88+
89+
def test_create_field_literal_annotation_produces_choices():
90+
"""
91+
_create_field must auto-derive choices from a Literal annotation.
92+
93+
This is the core of the fix: a provider field declared as
94+
`frequency: Literal["annual", "quarterly"]` must cause _create_field to emit
95+
{"multiple_items_allowed": False, "choices": ["annual", "quarterly"]} into the
96+
json_schema_extra under the provider's key, so that filter_inputs can validate
97+
user values before the merged ExtraParams dataclass is built.
98+
"""
99+
field = FieldInfo(annotation=Literal["annual", "quarterly"], default="quarterly")
100+
result = ProviderInterface._create_field("frequency", field, provider_name="oecd")
101+
extra = result.default.json_schema_extra
102+
assert extra is not None
103+
assert "oecd" in extra
104+
assert extra["oecd"]["choices"] == ["annual", "quarterly"]
105+
106+
107+
def test_create_field_optional_literal_annotation_produces_choices():
108+
"""
109+
_create_field must unwrap Optional[Literal[...]] and still derive choices.
110+
force_optional=True wraps Literal annotations in Optional — choices must survive.
111+
"""
112+
field = FieldInfo(annotation=Optional[Literal["annual", "quarterly"]], default=None)
113+
result = ProviderInterface._create_field(
114+
"frequency", field, provider_name="oecd", force_optional=True
115+
)
116+
extra = result.default.json_schema_extra
117+
assert extra is not None
118+
assert "oecd" in extra
119+
assert extra["oecd"]["choices"] == ["annual", "quarterly"]
120+
121+
122+
def test_create_field_str_annotation_produces_no_choices():
123+
"""
124+
_create_field must NOT produce choices for a plain str annotation.
125+
Only Literal annotations trigger auto-derivation.
126+
"""
127+
field = FieldInfo(annotation=str, default="quarterly")
128+
result = ProviderInterface._create_field("frequency", field, provider_name="oecd")
129+
extra = result.default.json_schema_extra if result.default else None
130+
# Either no extra at all, or extra does not contain choices for this provider
131+
if extra and "oecd" in extra:
132+
assert "choices" not in extra["oecd"]
133+
134+
135+
def test_create_field_explicit_choices_not_overwritten():
136+
"""
137+
When json_schema_extra already declares explicit choices for a provider,
138+
_create_field must not overwrite them with auto-derived Literal choices.
139+
Explicit choices take precedence.
140+
"""
141+
field = FieldInfo(
142+
annotation=Literal["annual", "quarterly"],
143+
default="quarterly",
144+
json_schema_extra={"oecd": {"choices": ["annual"]}},
145+
)
146+
result = ProviderInterface._create_field("frequency", field, provider_name="oecd")
147+
extra = result.default.json_schema_extra
148+
# The explicit single-item choices list must be preserved, not expanded to both values
149+
assert extra["oecd"]["choices"] == ["annual"]

0 commit comments

Comments
 (0)