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
3 changes: 2 additions & 1 deletion crates/fmp-py-gen/registry/statements.toml
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,8 @@ name = "json"
method = "financial_reports_json"
query = "FinancialReportsJsonQuery"
response = "statements::reports::FinancialReportJson"
doc = "Retrieves one dynamic JSON financial report as a bare array."
single = true
doc = "Retrieves one dynamic JSON financial report as a single object."
args = [
{ name = "symbol", kind = "ticker" },
{ name = "year", kind = "year" },
Expand Down
7 changes: 6 additions & 1 deletion crates/fmp-py-gen/src/bin/gen_go/methods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ impl QueryPlan {
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ResponseKind {
Rows(String),
Single(String),
Dynamic,
Binary(Vec<String>),
}
Expand Down Expand Up @@ -280,7 +281,11 @@ impl Context<'_> {
model.name
));
}
ResponseKind::Rows(go_name(&model.name))
if endpoint.single {
ResponseKind::Single(go_name(&model.name))
} else {
ResponseKind::Rows(go_name(&model.name))
}
}
(model, contract) => {
return Err(format!(
Expand Down
11 changes: 6 additions & 5 deletions crates/fmp-py-gen/src/bin/gen_go/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -336,9 +336,10 @@ fn render_method(namespace: &NamespacePlan, method: &MethodPlan, out: &mut Strin
.as_ref()
.map_or(String::new(), |query| format!(", q {query}"));
let (result, zero) = match &method.response {
ResponseKind::Rows(model) => (format!("[]{model}"), "nil"),
ResponseKind::Dynamic => ("[]jsontext.Value".to_string(), "nil"),
ResponseKind::Binary(_) => ("BinaryPayload".to_string(), "BinaryPayload{}"),
ResponseKind::Rows(model) => (format!("[]{model}"), "nil".to_string()),
ResponseKind::Single(model) => (model.clone(), format!("{model}{{}}")),
ResponseKind::Dynamic => ("[]jsontext.Value".to_string(), "nil".to_string()),
ResponseKind::Binary(_) => ("BinaryPayload".to_string(), "BinaryPayload{}".to_string()),
};
let _ = writeln!(
out,
Expand Down Expand Up @@ -367,11 +368,11 @@ fn render_method(namespace: &NamespacePlan, method: &MethodPlan, out: &mut Strin
types.join(", ")
);
}
ResponseKind::Rows(_) | ResponseKind::Dynamic => {
ResponseKind::Rows(_) | ResponseKind::Single(_) | ResponseKind::Dynamic => {
let _ = writeln!(
out,
"\tvar out {result}\n\tif err := n.client.getJSON(ctx, {id:?}, {path:?}, {params}, &out); err != nil {{\n\
\t\treturn nil, err\n\t}}"
\t\treturn {zero}, err\n\t}}"
);
if method.response == ResponseKind::Dynamic {
let _ = writeln!(
Expand Down
10 changes: 9 additions & 1 deletion crates/fmp-py-gen/src/bin/gen_namespaces/method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
//! mapping); argument conversion and query construction live in the builder
//! so an async twin can reuse them without touching this template. A
//! `binary = true` entry returns one `BinaryPayload` instead of a `Vec` of
//! models, with the same hand-off; a `response = "dynamic"` entry returns
//! models, with the same hand-off; a `single = true` entry returns one model;
//! a `response = "dynamic"` entry returns
//! each untyped row as a Python `dict` through `crate::convert`. A nested
//! builder is assembled in the query builder from its flattened keyword
//! arguments and passed whole to the query constructor.
Expand Down Expand Up @@ -161,6 +162,13 @@ fn result_shape(endpoint: &Endpoint) -> (String, &'static str, String) {
.response_model
.as_ref()
.map_or_else(|| "()".to_owned(), |model| model.name.clone());
if endpoint.single {
return (
model.clone(),
"row",
format!("row.map({model}::from).map_err(to_py_error)"),
);
}
(
format!("Vec<{model}>"),
"rows",
Expand Down
32 changes: 32 additions & 0 deletions crates/fmp-py-gen/src/bin/gen_namespaces/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,7 @@ fn synthetic(args: Vec<Arg>) -> Node {
setters: Vec::new(),
nested: Vec::new(),
binary: false,
single: false,
dynamic: false,
}],
children: Default::default(),
Expand Down Expand Up @@ -344,6 +345,37 @@ fn wide_signatures_get_the_clippy_allow_and_prefixed_input_types() {
assert!(source.contains("#[allow(clippy::too_many_arguments)]\nfn wide_query("));
}

#[test]
fn single_entries_return_one_model_instead_of_a_list() {
let modules: QueryModules = [(
"calendar.wide".to_owned(),
EntryModules {
query: vec!["calendar".to_owned()],
nested: BTreeMap::new(),
},
)]
.into_iter()
.collect();
let mut node = synthetic(vec![arg("symbol", ArgKind::Ticker, true)]);
node.endpoints[0].single = true;
let rendered = render(&node, &modules).expect("renders");
syn::parse_file(&rendered.source).expect("parses");
let source = rendered.source;
for needle in [
"fn wide(&self, py: Python<'_>, symbol: &str) -> PyResult<Row> {",
"let row = py.detach(move || block_on(builder, |client| async move { client.wide_call(query).await }))?;",
" row.map(Row::from).map_err(to_py_error)
}",
] {
assert!(
source.contains(needle),
"missing {needle:?} in:
{source}"
);
}
assert!(!source.contains("Vec<Row>"));
}

#[test]
fn struct_names_join_the_full_path() {
let path =
Expand Down
3 changes: 3 additions & 0 deletions crates/fmp-py-gen/src/registry/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ pub struct Endpoint {
pub nested: Vec<NestedBuilder>,
/// Whether the client method returns `BinaryResponse` rather than rows.
pub binary: bool,
/// Whether the client method returns one model rather than a `Vec` of
/// rows (registry `single = true`).
pub single: bool,
/// Whether the rows are untyped `DynamicObject`s handed to Python as
/// `dict`s (registry `response = "dynamic"`); no model is involved.
pub dynamic: bool,
Expand Down
17 changes: 15 additions & 2 deletions crates/fmp-py-gen/src/registry/scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ pub struct QueryApi {
pub enum Returns {
/// `Result<Vec<R>>`, carrying the row type name.
Rows(String),
/// `Result<R>` for one typed object, carrying its type name.
Single(String),
/// `Result<BinaryResponse>`.
Binary,
/// Anything else, carried verbatim for the error message.
Expand Down Expand Up @@ -300,7 +302,7 @@ fn client_method(file: &Path, sig: &syn::Signature) -> ClientMethod {
}
}

/// Reads `Result<Vec<R>>` and `Result<BinaryResponse>`.
/// Reads `Result<Vec<R>>`, `Result<BinaryResponse>`, and `Result<R>`.
fn classify_return(ty: &Type) -> Returns {
let verbatim = || Returns::Other(quote_type(ty));
let Some((head, inner)) = generic_head(ty) else {
Expand All @@ -311,7 +313,11 @@ fn classify_return(ty: &Type) -> Returns {
}
match generic_head(inner) {
Some((head, row)) if head == "Vec" => base_ident(row).map_or_else(verbatim, Returns::Rows),
None if base_ident(inner).as_deref() == Some("BinaryResponse") => Returns::Binary,
None => match base_ident(inner) {
Some(name) if name == "BinaryResponse" => Returns::Binary,
Some(name) => Returns::Single(name),
None => verbatim(),
},
_ => verbatim(),
}
}
Expand Down Expand Up @@ -442,6 +448,13 @@ mod tests {
surface.methods["financial_reports_xlsx"].returns,
Returns::Binary
);
let singles: Vec<&str> = surface
.methods
.iter()
.filter(|(_, method)| matches!(method.returns, Returns::Single(_)))
.map(|(name, _)| name.as_str())
.collect();
assert_eq!(singles, ["financial_reports_json"]);
}

#[test]
Expand Down
9 changes: 8 additions & 1 deletion crates/fmp-py-gen/src/registry/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
//! identifier validity, namespace path prefixes, duplicate names, setter to
//! argument references, and the `binary` versus `response` exclusivity.
//! `response = "dynamic"` marks an endpoint whose rows carry no typed model
//! (`Vec<DynamicObject>`) and reach Python as plain `dict`s. An arg carrying
//! (`Vec<DynamicObject>`) and reach Python as plain `dict`s; `single = true`
//! marks one typed model returned bare instead of a `Vec` of rows. An arg carrying
//! `nested` instead of `kind` is a builder passed to the constructor; its
//! setters are flattened into [`Endpoint::args`] as optional parameters.
//! Problems are collected per file so one load reports them all.
Expand Down Expand Up @@ -52,6 +53,8 @@ struct EndpointEntry {
setters: Vec<SetterEntry>,
#[serde(default)]
binary: bool,
#[serde(default)]
single: bool,
}

#[derive(Debug, Deserialize)]
Expand Down Expand Up @@ -233,6 +236,9 @@ fn lower_endpoint(
fail("args and setters need a `query` type".to_owned());
}
let dynamic = entry.response.as_deref() == Some(DYNAMIC_RESPONSE) && !entry.binary;
if entry.single && (entry.binary || dynamic) {
fail("`single = true` needs a typed `response` model".to_owned());
}
let response_model = match (&entry.response, entry.binary) {
(Some(_), true) => {
fail("`binary = true` entries must not name a `response` model".to_owned());
Expand Down Expand Up @@ -395,6 +401,7 @@ fn lower_endpoint(
setters,
nested,
binary: entry.binary,
single: entry.single,
dynamic,
}
}
Expand Down
11 changes: 11 additions & 0 deletions crates/fmp-py-gen/src/registry/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,17 @@ impl Checker<'_> {
Returns::Rows(_) if self.endpoint.binary => self.fail(format!(
"`Client::{method}` returns rows, not `BinaryResponse`; drop `binary = true`"
)),
Returns::Rows(_) if self.endpoint.single => self.fail(format!(
"`Client::{method}` returns rows, not one model; drop `single = true`"
)),
Returns::Single(row) if !self.endpoint.single => self.fail(format!(
"`Client::{method}` returns one `{row}`, not rows; set `single = true`"
)),
Returns::Single(row) => {
if let Some(model) = self.endpoint.response_model.clone() {
self.check_model(row, &model);
}
}
Returns::Rows(row) if row == DYNAMIC_ROW => {
if !self.endpoint.dynamic {
self.fail(format!(
Expand Down
23 changes: 23 additions & 0 deletions crates/fmp-py-gen/tests/fixtures/single_mismatch/statements.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
[[namespace]]
path = "statements.reports"

[[namespace.endpoint]]
name = "dates"
method = "financial_reports_dates"
query = "FinancialReportsDatesQuery"
response = "statements::reports::FinancialReportDate"
single = true
doc = "Marks a rows method as single."
args = [{ name = "symbol", kind = "ticker" }]

[[namespace.endpoint]]
name = "json"
method = "financial_reports_json"
query = "FinancialReportsJsonQuery"
response = "statements::reports::FinancialReportJson"
doc = "Leaves `single` off a method that returns one model."
args = [
{ name = "symbol", kind = "ticker" },
{ name = "year", kind = "year" },
{ name = "period", kind = "fiscal_period" },
]
25 changes: 25 additions & 0 deletions crates/fmp-py-gen/tests/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,24 @@ fn dynamic_response_verifies_only_against_dynamic_object_rows() {
assert_eq!(errors.len(), 2);
}

#[test]
fn single_flag_must_match_the_client_return_shape() {
let errors = validate(&fixture("single_mismatch"));
assert_names(
&errors,
"single_mismatch/statements.toml",
"statements.reports.dates",
"returns rows, not one model; drop `single = true`",
);
assert_names(
&errors,
"single_mismatch/statements.toml",
"statements.reports.json",
"returns one `FinancialReportJson`, not rows; set `single = true`",
);
assert_eq!(errors.len(), 2);
}

#[test]
fn unknown_method_is_a_hard_error() {
let errors = validate(&fixture("unknown_method"));
Expand Down Expand Up @@ -431,6 +449,7 @@ method = "quote"
query = "QuoteQuery"
response = "quote::Quote"
binary = true
single = true
doc = "Binary and response together."
args = [
{ name = "symbol", kind = "ticker" },
Expand All @@ -450,6 +469,12 @@ path = "other.sub"
panic!("expected RegistryError::Invalid, got {error}");
};
assert_names(&errors, "quote.toml", "quote.full", "`binary = true`");
assert_names(
&errors,
"quote.toml",
"quote.full",
"`single = true` needs a typed",
);
assert_names(
&errors,
"quote.toml",
Expand Down
4 changes: 3 additions & 1 deletion crates/fmp-py/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,9 @@ after a Python keyword (`from`) is spelled with a trailing underscore
(`from_`) on the Python side while the `libfmp` setter keeps its name.
Entries marked `binary = true` (the endpoints whose `libfmp` method returns
`BinaryResponse`, such as the XLSX financial report download) return a single
`BinaryPayload` instead of a list of models; entries marked
`BinaryPayload` instead of a list of models; entries marked `single = true`
(the endpoints whose `libfmp` method returns one model, such as the JSON
financial report) return that model alone; entries marked
`response = "dynamic"` return `list[dict[str, Any]]`. `BinaryPayload` is
hand-written in `src/binary.rs` and exported from the package root as
`fmp.BinaryPayload`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,9 @@ class StatementsReportsNamespace:
symbol: builtins.str,
year: builtins.int,
period: typing.Literal["Q1", "Q2", "Q3", "Q4", "FY"],
) -> builtins.list[FinancialReportJson]:
) -> FinancialReportJson:
r"""
Retrieves one dynamic JSON financial report as a bare array.
Retrieves one dynamic JSON financial report as a single object.
"""

def xlsx(
Expand Down
9 changes: 4 additions & 5 deletions crates/fmp-py/src/namespaces/statements/reports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ impl StatementsReportsNamespace {
.map_err(to_py_error)
}

/// Retrieves one dynamic JSON financial report as a bare array.
/// Retrieves one dynamic JSON financial report as a single object.
#[pyo3(signature = (symbol, year, period))]
fn json(
&self,
Expand All @@ -53,16 +53,15 @@ impl StatementsReportsNamespace {
year: i64,
#[gen_stub(override_type(type_repr = "typing.Literal[\"Q1\", \"Q2\", \"Q3\", \"Q4\", \"FY\"]", imports = ("typing",)))]
period: &str,
) -> PyResult<Vec<FinancialReportJson>> {
) -> PyResult<FinancialReportJson> {
let query = json_query(symbol, year, period)?;
let builder = self.builder.clone();
let rows = py.detach(move || {
let row = py.detach(move || {
block_on(builder, |client| async move {
client.financial_reports_json(query).await
})
})?;
rows.map(|items| items.into_iter().map(FinancialReportJson::from).collect())
.map_err(to_py_error)
row.map(FinancialReportJson::from).map_err(to_py_error)
}

/// Downloads one financial report using the endpoint's XLSX MIME policy.
Expand Down
26 changes: 17 additions & 9 deletions crates/fmp-py/tests/test_statements_reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,17 +68,25 @@ def test_report_dates_redact_links_until_exposed(client: Any, fixture_server: Fi


def test_report_json_with_year_and_fiscal_period(client: Any, fixture_server: FixtureServer) -> None:
"""``reports.json`` takes ``symbol``, ``year``, ``period`` positionally, in wire order."""
"""``reports.json`` takes ``symbol``, ``year``, ``period`` positionally and returns one report."""
fixture_server.route("/financial-reports-json", load_fixture("financial_reports_json.json"))
rows = client.statements.reports.json("AAPL", 2022, "Q3")
report = client.statements.reports.json("AAPL", 2023, "Q1")

assert fixture_server.requests[0].target == "/financial-reports-json?symbol=AAPL&year=2022&period=Q3"
assert len(rows) == 1
row = rows[0]
assert isinstance(row, FinancialReportJson)
assert (row.symbol, row.period, row.year) == ("AAPL", "FY", "2022")
assert isinstance(row.sections, dict)
assert row.sections["Auditor Information"][0] == {"Auditor Information": ["12 Months Ended"]}
assert fixture_server.requests[0].target == "/financial-reports-json?symbol=AAPL&year=2023&period=Q1"
assert isinstance(report, FinancialReportJson)
assert (report.symbol, report.period, report.year) == ("AAPL", "Q1", "2023")
assert isinstance(report.sections, dict)
assert len(report.sections) == 3
assert report.sections["Revenue - Additional Informatio"][2] == {"Total deferred revenue": [12.6, 12.4]}


def test_report_json_rejects_a_list_body(
client: Any, fixture_server: FixtureServer, errors: SimpleNamespace
) -> None:
"""A bare array is not a report: the single-object decode fails instead of taking the first row."""
fixture_server.route("/financial-reports-json", [load_fixture("financial_reports_json.json")])
with pytest.raises(errors.FmpDecodeError):
client.statements.reports.json("AAPL", 2023, "Q1")


def test_report_xlsx_returns_a_binary_payload(client: Any, fixture_server: FixtureServer) -> None:
Expand Down
Loading
Loading