From 95f0b1d1bce8aa63cb32fa6839af13af89c102d3 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Sat, 3 Oct 2026 01:41:43 +0800 Subject: [PATCH] fix(fmp-py): type DynamicObject getters as dict[str, Any] in stubs The getters backed by `DynamicObject` previously surfaced in the stubs as bare `typing.Any`, hiding the fact that they always return a JSON object. This made callers lose type information on `additional_columns`, `data`, and `sections` fields. **Code generation:** * Emit `override_return_type` with `builtins.dict[builtins.str, typing.Any]` for object passthrough getters, including the optional variant. * Add unit tests covering the number, object, and value getter stubs. **Model stubs and sources:** * Regenerate the `.pyi` stubs so the four object getters now read as `dict[str, Any]` instead of `Any`. * Annotate the corresponding Rust getters with the matching stub override. **Tests:** * Verify the former `Any` JSON-object fields are stubbed as str-keyed dicts and read back as real `dict` instances. * Extend the type-check contract to cover the new `sections` and segment `data` return types. close #409 --- crates/fmp-py-gen/src/bin/gen_models/model.rs | 46 +++++++++++++++++++ .../fmp/_native/congressional/__init__.pyi | 2 +- .../statements/as_reported/__init__.pyi | 2 +- .../_native/statements/reports/__init__.pyi | 2 +- .../statements/segmentation/__init__.pyi | 2 +- crates/fmp-py/src/models/congressional.rs | 1 + .../src/models/statements/as_reported.rs | 1 + .../fmp-py/src/models/statements/reports.rs | 1 + .../src/models/statements/segmentation.rs | 1 + crates/fmp-py/tests/test_models.py | 29 +++++++++++- crates/fmp-py/tests/typecheck_contract.py | 5 +- 11 files changed, 85 insertions(+), 7 deletions(-) diff --git a/crates/fmp-py-gen/src/bin/gen_models/model.rs b/crates/fmp-py-gen/src/bin/gen_models/model.rs index c8821cc5..48b02b0a 100644 --- a/crates/fmp-py-gen/src/bin/gen_models/model.rs +++ b/crates/fmp-py-gen/src/bin/gen_models/model.rs @@ -10,6 +10,9 @@ use crate::emit::{convert_expr, wrap_type}; /// The Python type a `serde_json::Number` field reads and accepts. const NUMBER_STUB: &str = "builtins.int | builtins.float"; +/// The Python type a `DynamicObject` (JSON object) field reads. +const OBJECT_STUB: &str = "builtins.dict[builtins.str, typing.Any]"; + /// How a base (non-composed) field type maps into the Python model. pub(crate) enum Class { Scalar { @@ -325,6 +328,12 @@ impl KeptField { (Pass::Number, true) => format!( " #[gen_stub(override_return_type(type_repr = \"{NUMBER_STUB} | None\", imports = (\"builtins\",)))]\n" ), + (Pass::Object, false) => format!( + " #[gen_stub(override_return_type(type_repr = \"{OBJECT_STUB}\", imports = (\"builtins\", \"typing\")))]\n" + ), + (Pass::Object, true) => format!( + " #[gen_stub(override_return_type(type_repr = \"{OBJECT_STUB} | None\", imports = (\"builtins\", \"typing\")))]\n" + ), _ => String::new(), }; Some(format!( @@ -430,3 +439,40 @@ fn is_valid_python_ident(name: &str) -> bool { } chars.all(|c| c == '_' || c.is_ascii_alphanumeric()) } + +#[cfg(test)] +mod tests { + use super::{KeptField, Pass}; + + fn getter(pass: Pass, optional: bool) -> String { + KeptField::passthrough("data".to_owned(), pass, optional) + .getter_method() + .expect("a passthrough field has a getter") + } + + #[test] + fn number_getters_stub_int_or_float() { + assert!(getter(Pass::Number, false).contains( + "override_return_type(type_repr = \"builtins.int | builtins.float\", imports = (\"builtins\",))" + )); + assert!(getter(Pass::Number, true).contains( + "override_return_type(type_repr = \"builtins.int | builtins.float | None\", imports = (\"builtins\",))" + )); + } + + #[test] + fn object_getters_stub_a_str_keyed_dict() { + assert!(getter(Pass::Object, false).contains( + "override_return_type(type_repr = \"builtins.dict[builtins.str, typing.Any]\", imports = (\"builtins\", \"typing\"))" + )); + assert!(getter(Pass::Object, true).contains( + "override_return_type(type_repr = \"builtins.dict[builtins.str, typing.Any] | None\", imports = (\"builtins\", \"typing\"))" + )); + } + + #[test] + fn value_getters_keep_the_inferred_any() { + assert!(!getter(Pass::Value, false).contains("gen_stub")); + assert!(!getter(Pass::Value, true).contains("gen_stub")); + } +} diff --git a/crates/fmp-py/python/fmp/_native/congressional/__init__.pyi b/crates/fmp-py/python/fmp/_native/congressional/__init__.pyi index d64e3fc7..9e9810f5 100644 --- a/crates/fmp-py/python/fmp/_native/congressional/__init__.pyi +++ b/crates/fmp-py/python/fmp/_native/congressional/__init__.pyi @@ -192,7 +192,7 @@ class CongressionalMemberNetWorthAggregate: @property def government_securities(self) -> typing.Optional[builtins.float]: ... @property - def additional_columns(self) -> typing.Any: ... + def additional_columns(self) -> builtins.dict[builtins.str, typing.Any]: ... def __eq__(self, other: builtins.object, /) -> builtins.bool: ... def __new__( cls, diff --git a/crates/fmp-py/python/fmp/_native/statements/as_reported/__init__.pyi b/crates/fmp-py/python/fmp/_native/statements/as_reported/__init__.pyi index 4219e6a3..a16c9d82 100644 --- a/crates/fmp-py/python/fmp/_native/statements/as_reported/__init__.pyi +++ b/crates/fmp-py/python/fmp/_native/statements/as_reported/__init__.pyi @@ -33,7 +33,7 @@ class AsReportedFinancialStatement: @property def period(self) -> typing.Literal["Q1", "Q2", "Q3", "Q4", "FY"]: ... @property - def data(self) -> typing.Any: ... + def data(self) -> builtins.dict[builtins.str, typing.Any]: ... def __eq__(self, other: builtins.object, /) -> builtins.bool: ... def __new__( cls, diff --git a/crates/fmp-py/python/fmp/_native/statements/reports/__init__.pyi b/crates/fmp-py/python/fmp/_native/statements/reports/__init__.pyi index d75a38eb..febda1d2 100644 --- a/crates/fmp-py/python/fmp/_native/statements/reports/__init__.pyi +++ b/crates/fmp-py/python/fmp/_native/statements/reports/__init__.pyi @@ -85,7 +85,7 @@ class FinancialReportJson: @property def period(self) -> typing.Literal["Q1", "Q2", "Q3", "Q4", "FY"]: ... @property - def sections(self) -> typing.Any: ... + def sections(self) -> builtins.dict[builtins.str, typing.Any]: ... def __eq__(self, other: builtins.object, /) -> builtins.bool: ... def __new__( cls, diff --git a/crates/fmp-py/python/fmp/_native/statements/segmentation/__init__.pyi b/crates/fmp-py/python/fmp/_native/statements/segmentation/__init__.pyi index 3146474b..f7082d97 100644 --- a/crates/fmp-py/python/fmp/_native/statements/segmentation/__init__.pyi +++ b/crates/fmp-py/python/fmp/_native/statements/segmentation/__init__.pyi @@ -33,7 +33,7 @@ class RevenueSegmentation: @property def period(self) -> typing.Literal["Q1", "Q2", "Q3", "Q4", "FY"]: ... @property - def data(self) -> typing.Any: ... + def data(self) -> builtins.dict[builtins.str, typing.Any]: ... def __eq__(self, other: builtins.object, /) -> builtins.bool: ... def __new__( cls, diff --git a/crates/fmp-py/src/models/congressional.rs b/crates/fmp-py/src/models/congressional.rs index 71d053ec..b5157cd0 100644 --- a/crates/fmp-py/src/models/congressional.rs +++ b/crates/fmp-py/src/models/congressional.rs @@ -1612,6 +1612,7 @@ impl CongressionalMemberNetWorthAggregate { } #[getter] + #[gen_stub(override_return_type(type_repr = "builtins.dict[builtins.str, typing.Any]", imports = ("builtins", "typing")))] fn additional_columns<'py>(&self, py: Python<'py>) -> PyResult> { crate::models::convert::object_to_py(py, &self.additional_columns) } diff --git a/crates/fmp-py/src/models/statements/as_reported.rs b/crates/fmp-py/src/models/statements/as_reported.rs index 8f7686ab..bdd76cad 100644 --- a/crates/fmp-py/src/models/statements/as_reported.rs +++ b/crates/fmp-py/src/models/statements/as_reported.rs @@ -163,6 +163,7 @@ impl AsReportedFinancialStatement { } #[getter] + #[gen_stub(override_return_type(type_repr = "builtins.dict[builtins.str, typing.Any]", imports = ("builtins", "typing")))] fn data<'py>(&self, py: Python<'py>) -> PyResult> { crate::models::convert::object_to_py(py, &self.data) } diff --git a/crates/fmp-py/src/models/statements/reports.rs b/crates/fmp-py/src/models/statements/reports.rs index 1ea4949a..1768d142 100644 --- a/crates/fmp-py/src/models/statements/reports.rs +++ b/crates/fmp-py/src/models/statements/reports.rs @@ -226,6 +226,7 @@ impl FinancialReportJson { } #[getter] + #[gen_stub(override_return_type(type_repr = "builtins.dict[builtins.str, typing.Any]", imports = ("builtins", "typing")))] fn sections<'py>(&self, py: Python<'py>) -> PyResult> { crate::models::convert::object_to_py(py, &self.sections) } diff --git a/crates/fmp-py/src/models/statements/segmentation.rs b/crates/fmp-py/src/models/statements/segmentation.rs index 17201af5..f0d3a9c1 100644 --- a/crates/fmp-py/src/models/statements/segmentation.rs +++ b/crates/fmp-py/src/models/statements/segmentation.rs @@ -163,6 +163,7 @@ impl RevenueSegmentation { } #[getter] + #[gen_stub(override_return_type(type_repr = "builtins.dict[builtins.str, typing.Any]", imports = ("builtins", "typing")))] fn data<'py>(&self, py: Python<'py>) -> PyResult> { crate::models::convert::object_to_py(py, &self.data) } diff --git a/crates/fmp-py/tests/test_models.py b/crates/fmp-py/tests/test_models.py index 72f77c1a..9da66620 100644 --- a/crates/fmp-py/tests/test_models.py +++ b/crates/fmp-py/tests/test_models.py @@ -26,6 +26,8 @@ NATIVE_ROOT = Path(str(files("fmp"))) / "_native" NUMBER = "builtins.int | builtins.float" +OBJECT = "builtins.dict[builtins.str, typing.Any]" +JSON_RETURNS = frozenset({"typing.Any", OBJECT, f"{OBJECT} | None"}) @dataclass(frozen=True) @@ -36,6 +38,7 @@ class ModelStub: name: str params: tuple[tuple[str, str], ...] json_params: frozenset[str] + object_params: frozenset[str] attributes: tuple[str, ...] @property @@ -61,10 +64,15 @@ def _model_stubs() -> dict[str, ModelStub]: json_params = frozenset( name for name, method in methods.items() - if method.returns is not None and ast.unparse(method.returns) == "typing.Any" + if method.returns is not None and ast.unparse(method.returns) in JSON_RETURNS ) attributes = tuple(name for name, _ in params if name in methods) - found[f"{module}.{node.name}"] = ModelStub(module, node.name, params, json_params, attributes) + object_params = frozenset( + name for name in attributes if ast.unparse(methods[name].returns or ast.Constant(None)).startswith(OBJECT) + ) + found[f"{module}.{node.name}"] = ModelStub( + module, node.name, params, json_params, object_params, attributes + ) return found @@ -247,3 +255,20 @@ def test_number_field_rejects_other_values(value: object, error: type[Exception] model = MODELS[path] with pytest.raises(error): model.cls(**{**kwargs_for(model), name: value}) + + +OBJECT_FIELDS = sorted((path, name) for path, model in MODELS.items() for name in model.object_params) + + +def test_object_fields_are_typed_str_keyed_dicts() -> None: + """The former ``Any`` JSON-object fields are stubbed ``dict[str, Any]``.""" + assert len(OBJECT_FIELDS) == 4 + + +@pytest.mark.parametrize(("path", "name"), OBJECT_FIELDS, ids=lambda part: part.removeprefix("fmp._native.")) +def test_object_field_reads_a_dict(path: str, name: str) -> None: + """An object field built from JSON text reads back as a ``dict``.""" + model = MODELS[path] + row = model.cls(**{**kwargs_for(model), name: '{"a": 1}'}) + assert getattr(row, name) == {"a": 1} + assert type(getattr(row, name)) is dict diff --git a/crates/fmp-py/tests/typecheck_contract.py b/crates/fmp-py/tests/typecheck_contract.py index 16ac9fd1..114812dd 100644 --- a/crates/fmp-py/tests/typecheck_contract.py +++ b/crates/fmp-py/tests/typecheck_contract.py @@ -124,7 +124,10 @@ def check_statements_contract(client: FmpClient) -> None: workbook: BinaryPayload = client.statements.reports.xlsx("AAPL", 2024, "FY") latest: list[LatestFinancialStatement] = client.statements.summaries.latest_financial_statements(page=0, limit=10) added: datetime.datetime = latest[0].date_added - _ = (reported_on, revenue, fiscal, link, workbook.data, added) + sections: dict[str, Any] = client.statements.reports.json("AAPL", 2024, "FY").sections + segments = client.statements.segmentation.revenue_product("AAPL", period="annual") + segment_data: dict[str, Any] = segments[0].data + _ = (reported_on, revenue, fiscal, link, workbook.data, added, sections, segment_data) def check_calendar_contract(client: FmpClient) -> None: