backport OptionsChainsData serialize schema fix from v5 branch (#7511)

This commit is contained in:
Danglewood
2026-05-25 22:33:14 -07:00
committed by GitHub
parent 89023948ee
commit 3735afca3f
2 changed files with 134 additions and 1 deletions

View File

@@ -4,6 +4,16 @@ from datetime import (
date as dateType,
datetime,
)
from typing import Any, get_args, get_origin
from pydantic import (
Field,
GetJsonSchemaHandler,
create_model,
field_validator,
model_serializer,
)
from pydantic.json_schema import JsonSchemaValue
from openbb_core.provider.abstract.query_params import QueryParams
from openbb_core.provider.utils.descriptions import (
@@ -11,7 +21,6 @@ from openbb_core.provider.utils.descriptions import (
QUERY_DESCRIPTIONS,
)
from openbb_core.provider.utils.options_chains_properties import OptionsChainsProperties
from pydantic import Field, field_validator, model_serializer
class OptionsChainsQueryParams(QueryParams):
@@ -369,3 +378,54 @@ class OptionsChainsData(OptionsChainsProperties):
records = [dict(zip(data.keys(), values)) for values in zip(*data.values())]
return records
@classmethod
def __get_pydantic_json_schema__(
cls,
core_schema: Any,
handler: GetJsonSchemaHandler,
) -> JsonSchemaValue:
"""Return a JSON schema that matches serialized row records."""
_ = (core_schema, handler)
row_fields: dict[str, Any] = {}
for field_name, field_info in cls.model_fields.items():
annotation = field_info.annotation
inner = (
get_args(annotation)[0]
if get_origin(annotation) is list
else annotation
)
if inner is datetime or (get_origin(inner) is None and inner is dateType):
inner = str | None
row_fields[field_name] = (
(
inner | None
if get_origin(inner) is None and inner is not type(None)
else inner
),
Field(
default=None,
description=field_info.description,
json_schema_extra=field_info.json_schema_extra,
),
)
row_model = create_model(f"{cls.__name__}Item", **row_fields) # type: ignore[call-overload]
items_schema = row_model.model_json_schema(
ref_template="#/components/schemas/{model}",
)
defs = items_schema.pop("$defs", None)
schema: JsonSchemaValue = {
"type": "array",
"description": (cls.__doc__ or "").split("\n\n", maxsplit=1)[0].strip(),
"items": items_schema,
"title": cls.__name__,
}
if defs:
schema["$defs"] = defs
return schema

View File

@@ -0,0 +1,73 @@
"""Tests for options chains serialization and schema shape."""
from datetime import date, datetime
from fastapi import FastAPI
from openbb_core.app.model.obbject import OBBject
from openbb_core.provider.standard_models.options_chains import OptionsChainsData
def _sample_payload() -> dict:
return {
"underlying_symbol": ["FAKE", "FAKE"],
"underlying_price": [100.0, 100.0],
"contract_symbol": ["FAKE240117C00100000", "FAKE240117P00100000"],
"expiration": [date(2030, 1, 17), date(2030, 1, 17)],
"strike": [100.0, 100.0],
"option_type": ["call", "put"],
"open_interest": [10, 5],
"volume": [100, 50],
"bid": [1.5, 1.4],
"ask": [1.6, 1.5],
"last_trade_time": [datetime(2026, 1, 1, 15, 30), datetime(2026, 1, 1, 15, 31)],
}
def test_options_chains_model_dump_is_row_records() -> None:
data = OptionsChainsData(**_sample_payload())
dumped = data.model_dump()
assert isinstance(dumped, list)
assert len(dumped) == 2
assert dumped[0]["option_type"] == "call"
assert dumped[1]["option_type"] == "put"
assert isinstance(dumped[0]["last_trade_time"], str)
def test_options_chains_json_schema_is_array_of_rows() -> None:
schema = OptionsChainsData.model_json_schema()
assert schema["type"] == "array"
assert schema["items"]["type"] == "object"
strike_types = {
entry.get("type") for entry in schema["items"]["properties"]["strike"]["anyOf"]
}
assert "number" in strike_types
assert "null" in strike_types
def test_fastapi_openapi_results_references_row_schema() -> None:
app = FastAPI()
@app.get("/chains")
def chains() -> OBBject[OptionsChainsData]:
return OBBject(results=[])
openapi = app.openapi()
components = openapi["components"]["schemas"]
assert components["OptionsChainsData"]["type"] == "array"
route_schema = openapi["paths"]["/chains"]["get"]["responses"]["200"]["content"][
"application/json"
]["schema"]
wrapper_name = route_schema["$ref"].rsplit("/", 1)[-1]
results_schema = components[wrapper_name]["properties"]["results"]
refs = [
entry.get("$ref")
for entry in results_schema.get("anyOf", [results_schema])
if entry.get("$ref")
]
assert any(ref.endswith("OptionsChainsData") for ref in refs)