Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions crates/fmp-py-gen/src/bin/gen_models/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/fmp-py/src/models/congressional.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Bound<'py, PyAny>> {
crate::models::convert::object_to_py(py, &self.additional_columns)
}
Expand Down
1 change: 1 addition & 0 deletions crates/fmp-py/src/models/statements/as_reported.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Bound<'py, PyAny>> {
crate::models::convert::object_to_py(py, &self.data)
}
Expand Down
1 change: 1 addition & 0 deletions crates/fmp-py/src/models/statements/reports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Bound<'py, PyAny>> {
crate::models::convert::object_to_py(py, &self.sections)
}
Expand Down
1 change: 1 addition & 0 deletions crates/fmp-py/src/models/statements/segmentation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Bound<'py, PyAny>> {
crate::models::convert::object_to_py(py, &self.data)
}
Expand Down
29 changes: 27 additions & 2 deletions crates/fmp-py/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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


Expand Down Expand Up @@ -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
5 changes: 4 additions & 1 deletion crates/fmp-py/tests/typecheck_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading