merge branch develop

This commit is contained in:
Danglewood
2026-05-25 22:43:18 -07:00
2 changed files with 86 additions and 37 deletions

View File

@@ -385,31 +385,22 @@ class OptionsChainsData(OptionsChainsProperties):
core_schema: Any,
handler: GetJsonSchemaHandler,
) -> JsonSchemaValue:
"""Generate a JSON schema that matches the wire response shape.
"""Return a JSON schema that matches serialized row records."""
_ = (core_schema, handler)
``OptionsChainsData`` stores data column-oriented (each field is a
``list[...]``) but the ``model_serialize`` ``@model_serializer`` above
transposes it into a list of row records when serializing. The default
Pydantic schema generated from the field annotations therefore
advertises an object-of-lists, which does not match what FastAPI
actually returns over the wire.
This override builds a transient row-shaped Pydantic model whose
fields are the inner type of each list field on ``OptionsChainsData``
(``strike: float | None`` instead of ``strike: list[float]``) and
returns ``{"type": "array", "items": <row_schema>}`` so the OpenAPI
document the API publishes accurately describes the JSON clients
actually receive.
"""
row_fields: dict[str, Any] = {}
for fname, finfo in cls.model_fields.items():
ann = finfo.annotation
inner = get_args(ann)[0] if get_origin(ann) is list else ann
# Datetime fields are stringified by ``model_serialize`` before
# the transpose, so reflect that in the schema as well.
if inner is datetime or get_origin(inner) is None and inner is dateType:
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[fname] = (
row_fields[field_name] = (
(
inner | None
if get_origin(inner) is None and inner is not type(None)
@@ -417,27 +408,24 @@ class OptionsChainsData(OptionsChainsProperties):
),
Field(
default=None,
description=finfo.description,
json_schema_extra=finfo.json_schema_extra,
description=field_info.description,
json_schema_extra=field_info.json_schema_extra,
),
)
row_model = create_model( # type: ignore[call-overload]
f"{cls.__name__}Item",
**row_fields,
)
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}",
)
# Inline ``$defs`` if Pydantic produced them so the array schema is
# self-contained when embedded in the parent OpenAPI document.
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: # pragma: no cover
if defs:
schema["$defs"] = defs
return schema

View File

@@ -1,19 +1,80 @@
"""Tests for ``OptionsChainsData`` — pure-Python serializer + OpenAPI schema fidelity.
These tests intentionally avoid pandas. They exercise the column→row
``@model_serializer`` and the ``__get_pydantic_json_schema__`` override that
keeps the OpenAPI document in sync with the actual JSON wire response.
"""
"""Tests for options chains serialization and schema shape."""
import json
from datetime import date, datetime
import pytest
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)
@pytest.fixture
def sample_payload() -> dict:
"""Smallest realistic payload — 2 rows, 1 expiration, 1 strike, call/put."""