From cb1adc189b61814576b4dffe8971256389297f4e Mon Sep 17 00:00:00 2001 From: benbenbang Date: Wed, 30 Sep 2026 15:39:02 +0800 Subject: [PATCH 1/5] feat(crates): support single-model endpoints in generators Add a `single = true` endpoint option that returns one typed model instead of a `Vec` of rows, alongside the existing `binary` and `dynamic` modes. **Registry and schema:** * Added the `single` flag to `Endpoint` and the schema entry, defaulting to false and deserialized from the registry TOML. * Introduced `Returns::Single` in the return classifier so `Result` is recognized as one typed object distinct from `Vec` and `BinaryResponse`. * Validated exclusivity: `single = true` needs a typed `response` model and must match a `Client` method that returns one model rather than rows. **Go and namespace generation:** * Added `ResponseKind::Single` and rendered it as a bare model return with a `Model{}` zero value instead of a slice and `nil`. * Emitted a single-model Python method that maps one row via `Model::from` rather than collecting a `Vec`. **Docs and tests:** * Documented the `single = true` mode in the module comments and README. * Added coverage for single-model rendering and the schema exclusivity error. --- crates/fmp-py-gen/src/bin/gen_go/methods.rs | 7 +++- crates/fmp-py-gen/src/bin/gen_go/render.rs | 11 ++++--- .../src/bin/gen_namespaces/method.rs | 10 +++++- .../src/bin/gen_namespaces/tests.rs | 32 +++++++++++++++++++ crates/fmp-py-gen/src/registry/mod.rs | 3 ++ crates/fmp-py-gen/src/registry/scan.rs | 10 ++++-- crates/fmp-py-gen/src/registry/schema.rs | 9 +++++- crates/fmp-py-gen/src/registry/validate.rs | 11 +++++++ crates/fmp-py-gen/tests/registry.rs | 7 ++++ crates/fmp-py/README.md | 4 ++- 10 files changed, 93 insertions(+), 11 deletions(-) diff --git a/crates/fmp-py-gen/src/bin/gen_go/methods.rs b/crates/fmp-py-gen/src/bin/gen_go/methods.rs index 62aa5edc..e640a178 100644 --- a/crates/fmp-py-gen/src/bin/gen_go/methods.rs +++ b/crates/fmp-py-gen/src/bin/gen_go/methods.rs @@ -69,6 +69,7 @@ impl QueryPlan { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum ResponseKind { Rows(String), + Single(String), Dynamic, Binary(Vec), } @@ -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!( diff --git a/crates/fmp-py-gen/src/bin/gen_go/render.rs b/crates/fmp-py-gen/src/bin/gen_go/render.rs index b8a00503..09ef28f6 100644 --- a/crates/fmp-py-gen/src/bin/gen_go/render.rs +++ b/crates/fmp-py-gen/src/bin/gen_go/render.rs @@ -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, @@ -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!( diff --git a/crates/fmp-py-gen/src/bin/gen_namespaces/method.rs b/crates/fmp-py-gen/src/bin/gen_namespaces/method.rs index ae9d38ee..7fcecaa8 100644 --- a/crates/fmp-py-gen/src/bin/gen_namespaces/method.rs +++ b/crates/fmp-py-gen/src/bin/gen_namespaces/method.rs @@ -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. @@ -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", diff --git a/crates/fmp-py-gen/src/bin/gen_namespaces/tests.rs b/crates/fmp-py-gen/src/bin/gen_namespaces/tests.rs index bf3af0bb..4d8a07a8 100644 --- a/crates/fmp-py-gen/src/bin/gen_namespaces/tests.rs +++ b/crates/fmp-py-gen/src/bin/gen_namespaces/tests.rs @@ -288,6 +288,7 @@ fn synthetic(args: Vec) -> Node { setters: Vec::new(), nested: Vec::new(), binary: false, + single: false, dynamic: false, }], children: Default::default(), @@ -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 {", + "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")); +} + #[test] fn struct_names_join_the_full_path() { let path = diff --git a/crates/fmp-py-gen/src/registry/mod.rs b/crates/fmp-py-gen/src/registry/mod.rs index 7ef8f7ea..81fc9485 100644 --- a/crates/fmp-py-gen/src/registry/mod.rs +++ b/crates/fmp-py-gen/src/registry/mod.rs @@ -102,6 +102,9 @@ pub struct Endpoint { pub nested: Vec, /// 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, diff --git a/crates/fmp-py-gen/src/registry/scan.rs b/crates/fmp-py-gen/src/registry/scan.rs index ebb0642c..dcce5680 100644 --- a/crates/fmp-py-gen/src/registry/scan.rs +++ b/crates/fmp-py-gen/src/registry/scan.rs @@ -50,6 +50,8 @@ pub struct QueryApi { pub enum Returns { /// `Result>`, carrying the row type name. Rows(String), + /// `Result` for one typed object, carrying its type name. + Single(String), /// `Result`. Binary, /// Anything else, carried verbatim for the error message. @@ -300,7 +302,7 @@ fn client_method(file: &Path, sig: &syn::Signature) -> ClientMethod { } } -/// Reads `Result>` and `Result`. +/// Reads `Result>`, `Result`, and `Result`. fn classify_return(ty: &Type) -> Returns { let verbatim = || Returns::Other(quote_type(ty)); let Some((head, inner)) = generic_head(ty) else { @@ -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(), } } diff --git a/crates/fmp-py-gen/src/registry/schema.rs b/crates/fmp-py-gen/src/registry/schema.rs index 11a644b9..8d0f3571 100644 --- a/crates/fmp-py-gen/src/registry/schema.rs +++ b/crates/fmp-py-gen/src/registry/schema.rs @@ -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`) and reach Python as plain `dict`s. An arg carrying +//! (`Vec`) 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. @@ -52,6 +53,8 @@ struct EndpointEntry { setters: Vec, #[serde(default)] binary: bool, + #[serde(default)] + single: bool, } #[derive(Debug, Deserialize)] @@ -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()); @@ -395,6 +401,7 @@ fn lower_endpoint( setters, nested, binary: entry.binary, + single: entry.single, dynamic, } } diff --git a/crates/fmp-py-gen/src/registry/validate.rs b/crates/fmp-py-gen/src/registry/validate.rs index 0a2feee1..27ec5dba 100644 --- a/crates/fmp-py-gen/src/registry/validate.rs +++ b/crates/fmp-py-gen/src/registry/validate.rs @@ -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!( diff --git a/crates/fmp-py-gen/tests/registry.rs b/crates/fmp-py-gen/tests/registry.rs index bee2aab7..f93e6376 100644 --- a/crates/fmp-py-gen/tests/registry.rs +++ b/crates/fmp-py-gen/tests/registry.rs @@ -431,6 +431,7 @@ method = "quote" query = "QuoteQuery" response = "quote::Quote" binary = true +single = true doc = "Binary and response together." args = [ { name = "symbol", kind = "ticker" }, @@ -450,6 +451,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", diff --git a/crates/fmp-py/README.md b/crates/fmp-py/README.md index 3b5d52c3..c7734b59 100644 --- a/crates/fmp-py/README.md +++ b/crates/fmp-py/README.md @@ -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`. From e191c5af706b4a1dcefd3c51cac501603890d969 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Wed, 30 Sep 2026 15:49:04 +0800 Subject: [PATCH 2/5] fix(statements)!: return one financial report json object not a list The `financial-reports-json` endpoint returns a single bare object, not an array. Correct the shape across libfmp, fmp-py, and sdk/go so the endpoint decodes into one `FinancialReportJson` value and rejects arrays. * libfmp: change `financial_reports_json` to yield `FinancialReportJson` instead of `Vec`, and update the registry entry with `single = true`. * fmp-py: return one `FinancialReportJson` from `json` rather than a list. * sdk/go: `StatementsReportsNamespace.JSON` now returns `FinancialReportJSON`, with a new `assertObjectFixtureParity` helper that proves a bare-object fixture round-trips and ignores unknown members. * Replace the mis-shaped documented fixture with a trimmed live AAPL 2023 Q1 capture (three headers plus three sections) that proves the object shape, and update the Rust and Go tests to decode one object and reject arrays. BREAKING CHANGE: financial_reports_json now returns a single FinancialReportJson instead of a list; drop the [0] / iteration. --- crates/fmp-py-gen/registry/statements.toml | 3 +- .../src/namespaces/statements/reports.rs | 9 +- .../src/endpoints/statements/reports.rs | 6 +- crates/libfmp/tests/fixtures/README.md | 12 +- .../fixtures/financial_reports_json.json | 1395 ++--------------- .../tests/statements_report_endpoints.rs | 4 +- .../tests/statements_report_responses.rs | 101 +- sdk/go/fixtures_test.go | 33 + sdk/go/statements.go | 10 +- sdk/go/statements_parity_test.go | 2 +- sdk/go/statements_reports_parity_test.go | 63 +- sdk/go/statements_test.go | 6 +- 12 files changed, 225 insertions(+), 1419 deletions(-) diff --git a/crates/fmp-py-gen/registry/statements.toml b/crates/fmp-py-gen/registry/statements.toml index 832db082..67f40566 100644 --- a/crates/fmp-py-gen/registry/statements.toml +++ b/crates/fmp-py-gen/registry/statements.toml @@ -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" }, diff --git a/crates/fmp-py/src/namespaces/statements/reports.rs b/crates/fmp-py/src/namespaces/statements/reports.rs index 1b2643de..3b7ce0ba 100644 --- a/crates/fmp-py/src/namespaces/statements/reports.rs +++ b/crates/fmp-py/src/namespaces/statements/reports.rs @@ -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, @@ -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> { + ) -> PyResult { 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. diff --git a/crates/libfmp/src/endpoints/statements/reports.rs b/crates/libfmp/src/endpoints/statements/reports.rs index a08367cf..235da1c8 100644 --- a/crates/libfmp/src/endpoints/statements/reports.rs +++ b/crates/libfmp/src/endpoints/statements/reports.rs @@ -118,7 +118,7 @@ pub fn financial_reports_dates( /// Describes `GET financial-reports-json` without binding it to a transport. pub fn financial_reports_json( query: FinancialReportsJsonQuery, -) -> EndpointSpec> { +) -> EndpointSpec { EndpointSpec::get("financial-reports-json", "financial-reports-json", query) } @@ -143,11 +143,11 @@ impl Client { self.execute(&financial_reports_dates(query.into())).await } - /// Retrieves one dynamic JSON financial report as a bare array. + /// Retrieves one dynamic JSON financial report as a single object. pub async fn financial_reports_json( &self, query: FinancialReportsJsonQuery, - ) -> Result> { + ) -> Result { self.execute(&financial_reports_json(query)).await } diff --git a/crates/libfmp/tests/fixtures/README.md b/crates/libfmp/tests/fixtures/README.md index 381d6b3d..3330ade8 100644 --- a/crates/libfmp/tests/fixtures/README.md +++ b/crates/libfmp/tests/fixtures/README.md @@ -50,10 +50,14 @@ network connection. the ones reported in the issue, not a recorded response body. It is the decode proof for `Volume = f64` in `quote_short_endpoint.rs`, `fmp-py/tests/test_client.py`, and `sdk/go/quote_parity_test.go`. -- **No captured live responses yet.** Nothing here was recorded from a live - call. When one is captured, add it under a `*_captured.json` name, note the - capture date and plan tier in the owning test, and resolve the matching row - in `docs/contract-ambiguities.md`. +- **Trimmed live capture.** `financial_reports_json.json` is the one fixture + taken from a live call (AAPL, 2023, Q1, captured 2026-09-30 for issue + #374): the three headers plus three of its 48 sections, verbatim. It proves + the endpoint returns one bare object, not an array. It keeps the endpoint + name because the documented sample it replaced had the wrong shape. +- **Other captured live responses.** When one is captured, add it under a + `*_captured.json` name, note the capture date and plan tier in the owning + test, and resolve the matching row in `docs/contract-ambiguities.md`. Formatting is not wire-faithful: the `pretty-format-json` pre-commit hook re-indents every file. Only the content (keys, value kinds, spelling, diff --git a/crates/libfmp/tests/fixtures/financial_reports_json.json b/crates/libfmp/tests/fixtures/financial_reports_json.json index 5499eff7..8b8d73e8 100644 --- a/crates/libfmp/tests/fixtures/financial_reports_json.json +++ b/crates/libfmp/tests/fixtures/financial_reports_json.json @@ -1,1305 +1,90 @@ -[ - { - "Auditor Information": [ - { - "Auditor Information": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022" - ] - }, - { - "Auditor Information [Abstract]": [ - "\u00a0" - ] - } - ], - "Benefit Plans": [ - { - "Benefit Plans": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022" - ] - }, - { - "Share-Based Payment Arrangement [Abstract]": [ - "\u00a0" - ] - } - ], - "Benefit Plans (Tables)": [ - { - "Benefit Plans (Tables)": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022" - ] - }, - { - "Share-Based Payment Arrangement [Abstract]": [ - "\u00a0" - ] - } - ], - "Benefit Plans - Additional Info": [ - { - "Benefit Plans - Additional Information (Details) shares in Millions": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022 USD ($) shares", - "Sep. 25, 2021 USD ($) shares", - "Sep. 26, 2020 USD ($) shares", - "Mar. 04, 2022 shares", - "Nov. 09, 2021 shares", - "Mar. 10, 2015 shares" - ] - }, - { - "Share-based Compensation Arrangement by Share-based Payment Award [Line Items]": [ - "\u00a0", - "\u00a0", - "\u00a0", - "\u00a0", - "\u00a0", - "\u00a0" - ] - } - ], - "Benefit Plans - Restricted Stoc": [ - { - "Benefit Plans - Restricted Stock Units Activity and Related Information (Details) - Restricted stock units - USD ($) $ / shares in Units, shares in Thousands, $ in Millions": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022", - "Sep. 25, 2021", - "Sep. 26, 2020" - ] - }, - { - "Number of Restricted Stock Units": [ - "\u00a0", - "\u00a0", - "\u00a0" - ] - } - ], - "Benefit Plans - Summary of Shar": [ - { - "Benefit Plans - Summary of Share-Based Compensation Expense and the Related Income Tax Benefit (Details) - USD ($) $ in Millions": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022", - "Sep. 25, 2021", - "Sep. 26, 2020" - ] - }, - { - "Share-Based Payment Arrangement [Abstract]": [ - "\u00a0", - "\u00a0", - "\u00a0" - ] - } - ], - "CONSOLIDATED BALANCE SHEETS": [ - { - "CONSOLIDATED BALANCE SHEETS - USD ($) $ in Millions": [ - "Sep. 24, 2022", - "Sep. 25, 2021" - ] - }, - { - "Current assets:": [ - "\u00a0", - "\u00a0" - ] - }, - { - "Cash and cash equivalents": [ - 23646, - 34940 - ] - } - ], - "CONSOLIDATED BALANCE SHEETS (Pa": [ - { - "CONSOLIDATED BALANCE SHEETS (Parenthetical) - $ / shares": [ - "Sep. 24, 2022", - "Sep. 25, 2021" - ] - }, - { - "Statement of Financial Position [Abstract]": [ - "\u00a0", - "\u00a0" - ] - }, - { - "Common stock, par value (in dollars per share)": [ - 1e-05, - 1e-05 - ] - } - ], - "CONSOLIDATED STATEMENTS OF CASH": [ - { - "CONSOLIDATED STATEMENTS OF CASH FLOWS - USD ($) $ in Millions": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022", - "Sep. 25, 2021", - "Sep. 26, 2020" - ] - }, - { - "Statement of Cash Flows [Abstract]": [ - "\u00a0", - "\u00a0", - "\u00a0" - ] - } - ], - "CONSOLIDATED STATEMENTS OF COMP": [ - { - "CONSOLIDATED STATEMENTS OF COMPREHENSIVE INCOME - USD ($) $ in Millions": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022", - "Sep. 25, 2021", - "Sep. 26, 2020" - ] - }, - { - "Statement of Comprehensive Income [Abstract]": [ - "\u00a0", - "\u00a0", - "\u00a0" - ] - } - ], - "CONSOLIDATED STATEMENTS OF OPER": [ - { - "CONSOLIDATED STATEMENTS OF OPERATIONS - USD ($) shares in Thousands, $ in Millions": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022", - "Sep. 25, 2021", - "Sep. 26, 2020" - ] - }, - { - "Net sales": [ - 394328, - 365817, - 274515 - ] - } - ], - "CONSOLIDATED STATEMENTS OF SHAR": [ - { - "CONSOLIDATED STATEMENTS OF SHAREHOLDERS' EQUITY - USD ($) $ in Millions": [ - "Total", - "Common stock and additional paid-in capital", - "Retained earnings/(Accumulated deficit)", - "Retained earnings/(Accumulated deficit) Cumulative effect of change in accounting principle", - "Accumulated other comprehensive income/(loss)", - "Accumulated other comprehensive income/(loss) Cumulative effect of change in accounting principle" - ] - }, - { - "Beginning balances at Sep. 28, 2019": [ - 90488, - 45174, - 45898, - -136, - -584, - 136 - ] - }, - { - "Increase (Decrease) in Stockholders' Equity [Roll Forward]": [ - "\u00a0", - "\u00a0", - "\u00a0", - "\u00a0", - "\u00a0", - "\u00a0" - ] - } - ], - "Commitments and Contingencies": [ - { - "Commitments and Contingencies": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022" - ] - }, - { - "Commitments and Contingencies Disclosure [Abstract]": [ - "\u00a0" - ] - } - ], - "Commitments and Contingencies (": [ - { - "Commitments and Contingencies (Tables)": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022" - ] - }, - { - "Commitments and Contingencies Disclosure [Abstract]": [ - "\u00a0" - ] - } - ], - "Commitments and Contingencies -": [ - { - "Commitments and Contingencies - Future Payments Under Unconditional Purchase Obligations (Details) $ in Millions": [ - "Sep. 24, 2022 USD ($)" - ] - }, - { - "Unconditional Purchase Obligation, Fiscal Year Maturity [Abstract]": [ - "\u00a0" - ] - }, - { - "2023": [ - 13488 - ] - } - ], - "Consolidated Financial Statem_2": [ - { - "Consolidated Financial Statement Details (Tables)": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022" - ] - }, - { - "Organization, Consolidation and Presentation of Financial Statements [Abstract]": [ - "\u00a0" - ] - } - ], - "Consolidated Financial Statem_3": [ - { - "Consolidated Financial Statement Details - Property, Plant and Equipment, Net (Details) - USD ($) $ in Millions": [ - "Sep. 24, 2022", - "Sep. 25, 2021" - ] - }, - { - "Property, Plant and Equipment [Line Items]": [ - "\u00a0", - "\u00a0" - ] - }, - { - "Gross property, plant and equipment": [ - 114457, - 109723 - ] - } - ], - "Consolidated Financial Statem_4": [ - { - "Consolidated Financial Statement Details - Other Non-Current Liabilities (Details) - USD ($) $ in Millions": [ - "Sep. 24, 2022", - "Sep. 25, 2021" - ] - }, - { - "Organization, Consolidation and Presentation of Financial Statements [Abstract]": [ - "\u00a0", - "\u00a0" - ] - }, - { - "Long-term taxes payable": [ - 16657, - 24689 - ] - } - ], - "Consolidated Financial Statem_5": [ - { - "Consolidated Financial Statement Details - Other Income/(Expense), Net (Details) - USD ($) $ in Millions": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022", - "Sep. 25, 2021", - "Sep. 26, 2020" - ] - }, - { - "Organization, Consolidation and Presentation of Financial Statements [Abstract]": [ - "\u00a0", - "\u00a0", - "\u00a0" - ] - } - ], - "Consolidated Financial Statemen": [ - { - "Consolidated Financial Statement Details": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022" - ] - }, - { - "Organization, Consolidation and Presentation of Financial Statements [Abstract]": [ - "\u00a0" - ] - } - ], - "Cover Page": [ - { - "Cover Page - USD ($) shares in Thousands, $ in Millions": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022", - "Oct. 14, 2022", - "Mar. 25, 2022" - ] - }, - { - "Entity Information [Line Items]": [ - "\u00a0", - "\u00a0", - "\u00a0" - ] - } - ], - "Debt": [ - { - "Debt": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022" - ] - }, - { - "Debt Disclosure [Abstract]": [ - "\u00a0" - ] - } - ], - "Debt (Tables)": [ - { - "Debt (Tables)": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022" - ] - }, - { - "Debt Disclosure [Abstract]": [ - "\u00a0" - ] - } - ], - "Debt - Additional Information (": [ - { - "Debt - Additional Information (Details) - USD ($) $ in Millions": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022", - "Sep. 25, 2021", - "Sep. 26, 2020" - ] - }, - { - "Debt Instrument [Line Items]": [ - "\u00a0", - "\u00a0", - "\u00a0" - ] - } - ], - "Debt - Future Principal Payment": [ - { - "Debt - Future Principal Payments for Term Debt (Details) - USD ($) $ in Millions": [ - "Sep. 24, 2022", - "Sep. 25, 2021" - ] - }, - { - "Debt Disclosure [Abstract]": [ - "\u00a0", - "\u00a0" - ] - }, - { - "2023": [ - 11139, - "\u00a0" - ] - } - ], - "Debt - Summary of Cash Flows As": [ - { - "Debt - Summary of Cash Flows Associated with Commercial Paper (Details) - USD ($) $ in Millions": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022", - "Sep. 25, 2021", - "Sep. 26, 2020" - ] - }, - { - "Maturities 90 days or less:": [ - "\u00a0", - "\u00a0", - "\u00a0" - ] - } - ], - "Debt - Summary of Term Debt (De": [ - { - "Debt - Summary of Term Debt (Details) - USD ($) $ in Millions": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022", - "Sep. 25, 2021" - ] - }, - { - "Debt Instrument [Line Items]": [ - "\u00a0", - "\u00a0" - ] - } - ], - "Financial Instruments": [ - { - "Financial Instruments": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022" - ] - }, - { - "Investments, All Other Investments [Abstract]": [ - "\u00a0" - ] - } - ], - "Financial Instruments (Tables)": [ - { - "Financial Instruments (Tables)": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022" - ] - }, - { - "Investments, All Other Investments [Abstract]": [ - "\u00a0" - ] - } - ], - "Financial Instruments - Additio": [ - { - "Financial Instruments - Additional Information (Details) $ in Millions": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022 USD ($) Customer Vendor", - "Sep. 25, 2021 Vendor" - ] - }, - { - "Financial Instruments [Line Items]": [ - "\u00a0", - "\u00a0" - ] - } - ], - "Financial Instruments - Cash, C": [ - { - "Financial Instruments - Cash, Cash Equivalents and Marketable Securities (Details) - USD ($) $ in Millions": [ - "Sep. 24, 2022", - "Sep. 25, 2021" - ] - }, - { - "Debt Securities, Available-for-sale [Line Items]": [ - "\u00a0", - "\u00a0" - ] - }, - { - "Cash, Cash Equivalents and Marketable Securities, Adjusted Cost": [ - 183061, - 189961 - ] - } - ], - "Financial Instruments - Derivat": [ - { - "Financial Instruments - Derivative Instruments Designated as Fair Value Hedges and Related Hedged Items (Details) - USD ($) $ in Millions": [ - "Sep. 24, 2022", - "Sep. 25, 2021" - ] - }, - { - "Current and non-current marketable securities": [ - "\u00a0", - "\u00a0" - ] - }, - { - "Derivatives, Fair Value [Line Items]": [ - "\u00a0", - "\u00a0" - ] - } - ], - "Financial Instruments - Gross F": [ - { - "Financial Instruments - Gross Fair Values of Derivative Assets and Liabilities (Details) - Level 2 $ in Millions": [ - "Sep. 24, 2022 USD ($)" - ] - }, - { - "Other current assets and other non-current assets | Foreign exchange contracts": [ - "\u00a0" - ] - }, - { - "Derivative assets:": [ - "\u00a0" - ] - } - ], - "Financial Instruments - Non-Cur": [ - { - "Financial Instruments - Non-Current Marketable Debt Securities by Contractual Maturity (Details) $ in Millions": [ - "Sep. 24, 2022 USD ($)" - ] - }, - { - "Fair value of non-current marketable debt securities by contractual maturity": [ - "\u00a0" - ] - }, - { - "Due after 1 year through 5 years": [ - 87031 - ] - } - ], - "Financial Instruments - Notiona": [ - { - "Financial Instruments - Notional Amounts Associated with Derivative Instruments (Details) - USD ($) $ in Millions": [ - "Sep. 24, 2022", - "Sep. 25, 2021" - ] - }, - { - "Derivatives designated as accounting hedges | Foreign exchange contracts": [ - "\u00a0", - "\u00a0" - ] - }, - { - "Derivative [Line Items]": [ - "\u00a0", - "\u00a0" - ] - } - ], - "Income Taxes": [ - { - "Income Taxes": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022" - ] - }, - { - "Income Tax Disclosure [Abstract]": [ - "\u00a0" - ] - } - ], - "Income Taxes (Tables)": [ - { - "Income Taxes (Tables)": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022" - ] - }, - { - "Income Tax Disclosure [Abstract]": [ - "\u00a0" - ] - } - ], - "Income Taxes - Additional Infor": [ - { - "Income Taxes - Additional Information (Details) $ in Millions, \u20ac in Billions": [ - null, - "12 Months Ended" - ] - }, - { - "items": [ - "Aug. 30, 2016 EUR (\u20ac) Subsidiary", - "Sep. 24, 2022 USD ($)", - "Sep. 25, 2021 USD ($)", - "Sep. 26, 2020 USD ($)", - "Sep. 24, 2022 EUR (\u20ac)", - "Sep. 28, 2019 USD ($)" - ] - }, - { - "Income Tax Contingency [Line Items]": [ - "\u00a0", - "\u00a0", - "\u00a0", - "\u00a0", - "\u00a0", - "\u00a0" - ] - } - ], - "Income Taxes - Aggregate Change": [ - { - "Income Taxes - Aggregate Changes in Gross Unrecognized Tax Benefits (Details) - USD ($) $ in Millions": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022", - "Sep. 25, 2021", - "Sep. 26, 2020" - ] - }, - { - "Reconciliation of Unrecognized Tax Benefits, Excluding Amounts Pertaining to Examined Tax Returns [Roll Forward]": [ - "\u00a0", - "\u00a0", - "\u00a0" - ] - } - ], - "Income Taxes - Provision for In": [ - { - "Income Taxes - Provision for Income Taxes (Details) - USD ($) $ in Millions": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022", - "Sep. 25, 2021", - "Sep. 26, 2020" - ] - }, - { - "Federal:": [ - "\u00a0", - "\u00a0", - "\u00a0" - ] - } - ], - "Income Taxes - Reconciliation o": [ - { - "Income Taxes - Reconciliation of Provision for Income Taxes to Amount Computed by Applying the Statutory Federal Income Tax Rate to Income Before Provision for Income Taxes (Details) - USD ($) $ in Millions": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022", - "Sep. 25, 2021", - "Sep. 26, 2020" - ] - }, - { - "Income Tax Disclosure [Abstract]": [ - "\u00a0", - "\u00a0", - "\u00a0" - ] - } - ], - "Income Taxes - Significant Comp": [ - { - "Income Taxes - Significant Components of Deferred Tax Assets and Liabilities (Details) - USD ($) $ in Millions": [ - "Sep. 24, 2022", - "Sep. 25, 2021" - ] - }, - { - "Deferred tax assets:": [ - "\u00a0", - "\u00a0" - ] - }, - { - "Amortization and depreciation": [ - 1496, - 5575 - ] - } - ], - "Leases": [ - { - "Leases": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022" - ] - }, - { - "Leases [Abstract]": [ - "\u00a0" - ] - } - ], - "Leases (Tables)": [ - { - "Leases (Tables)": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022" - ] - }, - { - "Leases [Abstract]": [ - "\u00a0" - ] - } - ], - "Leases - Additional Information": [ - { - "Leases - Additional Information (Details) - USD ($) $ in Billions": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022", - "Sep. 25, 2021", - "Sep. 26, 2020" - ] - }, - { - "Lessee, Lease, Description [Line Items]": [ - "\u00a0", - "\u00a0", - "\u00a0" - ] - } - ], - "Leases - Lease Liability Maturi": [ - { - "Leases - Lease Liability Maturities (Details) - USD ($) $ in Millions": [ - "Sep. 24, 2022", - "Sep. 25, 2021" - ] - }, - { - "Operating Leases": [ - "\u00a0", - "\u00a0" - ] - }, - { - "2023": [ - 1758, - "\u00a0" - ] - } - ], - "Leases - ROU Assets and Lease L": [ - { - "Leases - ROU Assets and Lease Liabilities (Details) - USD ($) $ in Millions": [ - "Sep. 24, 2022", - "Sep. 25, 2021" - ] - }, - { - "Lease-Related Assets and Liabilities": [ - "\u00a0", - "\u00a0" - ] - }, - { - "Operating lease right-of-use assets": [ - 10417, - 10087 - ] - } - ], - "Revenue": [ - { - "Revenue": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022" - ] - }, - { - "Revenue from Contract with Customer [Abstract]": [ - "\u00a0" - ] - } - ], - "Revenue (Tables)": [ - { - "Revenue (Tables)": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022" - ] - }, - { - "Revenue from Contract with Customer [Abstract]": [ - "\u00a0" - ] - } - ], - "Revenue - Additional Informatio": [ - { - "Revenue - Additional Information (Details) - USD ($) $ in Billions": [ - "Sep. 24, 2022", - "Sep. 25, 2021" - ] - }, - { - "Revenue from Contract with Customer [Abstract]": [ - "\u00a0", - "\u00a0" - ] - }, - { - "Total deferred revenue": [ - 12.4, - 11.9 - ] - } - ], - "Revenue - Deferred Revenue, Exp": [ - { - "Revenue - Deferred Revenue, Expected Timing of Realization (Details)": [ - "Sep. 24, 2022" - ] - }, - { - "Revenue, Remaining Performance Obligation, Expected Timing of Satisfaction, Start Date [Axis]: 2022-09-25": [ - "\u00a0" - ] - }, - { - "Revenue, Remaining Performance Obligation, Expected Timing of Satisfaction [Line Items]": [ - "\u00a0" - ] - } - ], - "Revenue - Net Sales Disaggregat": [ - { - "Revenue - Net Sales Disaggregated by Significant Products and Services (Details) - USD ($) $ in Millions": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022", - "Sep. 25, 2021", - "Sep. 26, 2020" - ] - }, - { - "Disaggregation of Revenue [Line Items]": [ - "\u00a0", - "\u00a0", - "\u00a0" - ] - } - ], - "Segment Information and Geogr_2": [ - { - "Segment Information and Geographic Data (Tables)": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022" - ] - }, - { - "Segment Reporting [Abstract]": [ - "\u00a0" - ] - } - ], - "Segment Information and Geogr_3": [ - { - "Segment Information and Geographic Data - Information by Reportable Segment (Details) - USD ($) $ in Millions": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022", - "Sep. 25, 2021", - "Sep. 26, 2020" - ] - }, - { - "Segment Reporting Information [Line Items]": [ - "\u00a0", - "\u00a0", - "\u00a0" - ] - } - ], - "Segment Information and Geogr_4": [ - { - "Segment Information and Geographic Data - Reconciliation of Segment Operating Income to the Consolidated Statements of Operations (Details) - USD ($) $ in Millions": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022", - "Sep. 25, 2021", - "Sep. 26, 2020" - ] - }, - { - "Segment Reporting, Reconciling Item for Operating Profit (Loss) from Segment to Consolidated [Line Items]": [ - "\u00a0", - "\u00a0", - "\u00a0" - ] - } - ], - "Segment Information and Geogr_5": [ - { - "Segment Information and Geographic Data - Net Sales (Details) - USD ($) $ in Millions": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022", - "Sep. 25, 2021", - "Sep. 26, 2020" - ] - }, - { - "Revenues from External Customers and Long-Lived Assets [Line Items]": [ - "\u00a0", - "\u00a0", - "\u00a0" - ] - } - ], - "Segment Information and Geogr_6": [ - { - "Segment Information and Geographic Data - Long-Lived Assets (Details) - USD ($) $ in Millions": [ - "Sep. 24, 2022", - "Sep. 25, 2021" - ] - }, - { - "Revenues from External Customers and Long-Lived Assets [Line Items]": [ - "\u00a0", - "\u00a0" - ] - }, - { - "Long-lived assets": [ - 42117, - 39440 - ] - } - ], - "Segment Information and Geograp": [ - { - "Segment Information and Geographic Data": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022" - ] - }, - { - "Segment Reporting [Abstract]": [ - "\u00a0" - ] - } - ], - "Shareholders' Equity": [ - { - "Shareholders' Equity": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022" - ] - }, - { - "Equity [Abstract]": [ - "\u00a0" - ] - } - ], - "Shareholders' Equity (Tables)": [ - { - "Shareholders' Equity (Tables)": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022" - ] - }, - { - "Equity [Abstract]": [ - "\u00a0" - ] - } - ], - "Shareholders' Equity - Addition": [ - { - "Shareholders' Equity - Additional Information (Details) shares in Millions, $ in Billions": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022 USD ($) shares" - ] - }, - { - "Stockholders' Equity Note [Abstract]": [ - "\u00a0" - ] - } - ], - "Shareholders' Equity - Shares o": [ - { - "Shareholders' Equity - Shares of Common Stock (Details) - shares shares in Thousands": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022", - "Sep. 25, 2021", - "Sep. 26, 2020" - ] - }, - { - "Increase (Decrease) in Shares of Common Stock Outstanding [Roll Forward]": [ - "\u00a0", - "\u00a0", - "\u00a0" - ] - } - ], - "Summary of Significant Accoun_2": [ - { - "Summary of Significant Accounting Policies (Policies)": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022" - ] - }, - { - "Accounting Policies [Abstract]": [ - "\u00a0" - ] - } - ], - "Summary of Significant Accoun_3": [ - { - "Summary of Significant Accounting Policies (Tables)": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022" - ] - }, - { - "Accounting Policies [Abstract]": [ - "\u00a0" - ] - } - ], - "Summary of Significant Accoun_4": [ - { - "Summary of Significant Accounting Policies - Additional Information (Details) $ in Billions": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022 USD ($) performanceObligation", - "Sep. 25, 2021 USD ($)", - "Sep. 26, 2020 USD ($)" - ] - }, - { - "Significant Accounting Policies [Line Items]": [ - "\u00a0", - "\u00a0", - "\u00a0" - ] - } - ], - "Summary of Significant Accoun_5": [ - { - "Summary of Significant Accounting Policies - Computation of Basic and Diluted Earnings Per Share (Details) - USD ($) $ / shares in Units, shares in Thousands, $ in Millions": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022", - "Sep. 25, 2021", - "Sep. 26, 2020" - ] - }, - { - "Numerator:": [ - "\u00a0", - "\u00a0", - "\u00a0" - ] - } - ], - "Summary of Significant Accounti": [ - { - "Summary of Significant Accounting Policies": [ - "12 Months Ended" - ] - }, - { - "items": [ - "Sep. 24, 2022" - ] - }, - { - "Accounting Policies [Abstract]": [ - "\u00a0" - ] - } - ], - "period": "FY", - "symbol": "AAPL", - "year": "2022" - } -] +{ + "CONDENSED CONSOLIDATED BALANC_2": [ + { + "CONDENSED CONSOLIDATED BALANCE SHEETS (Unaudited) (Parenthetical) - $ / shares": [ + "Dec. 31, 2022", + "Sep. 24, 2022" + ] + }, + { + "Statement of Financial Position [Abstract]": [ + "\u00a0", + "\u00a0" + ] + }, + { + "Common stock, par value (in dollars per share)": [ + 1e-05, + 1e-05 + ] + }, + { + "Common stock, shares authorized (in shares)": [ + 50400000000, + 50400000000 + ] + }, + { + "Common stock, shares issued (in shares)": [ + 15842407000, + 15943425000 + ] + }, + { + "Common stock, shares outstanding (in shares)": [ + 15842407000, + 15943425000 + ] + } + ], + "Revenue - Additional Informatio": [ + { + "Revenue - Additional Information (Details) - USD ($) $ in Billions": [ + "Dec. 31, 2022", + "Sep. 24, 2022" + ] + }, + { + "Revenue from Contract with Customer [Abstract]": [ + "\u00a0", + "\u00a0" + ] + }, + { + "Total deferred revenue": [ + 12.6, + 12.4 + ] + } + ], + "Shareholders' Equity - Addition": [ + { + "Shareholders' Equity - Additional Information (Details) shares in Millions, $ in Billions": [ + "3 Months Ended" + ] + }, + { + "items": [ + "Dec. 31, 2022 USD ($) shares" + ] + }, + { + "Share Repurchase Program [Line Items]": [ + "\u00a0" + ] + }, + { + "Number of shares repurchased (in shares) | shares": [ + 133 + ] + }, + { + "Amount of share repurchases | $": [ + 19 + ] + } + ], + "period": "Q1", + "symbol": "AAPL", + "year": "2023" +} diff --git a/crates/libfmp/tests/statements_report_endpoints.rs b/crates/libfmp/tests/statements_report_endpoints.rs index c362f593..bb14d181 100644 --- a/crates/libfmp/tests/statements_report_endpoints.rs +++ b/crates/libfmp/tests/statements_report_endpoints.rs @@ -148,8 +148,8 @@ async fn custom_proxy_preserves_exact_queries_auth_headers_and_all_three_respons .unwrap(); assert_eq!(dates[0].fiscal_year.get(), 2026); - assert_eq!(report[0].year.as_str(), "2022"); - assert_eq!(report[0].sections.len(), 67); + assert_eq!(report.year.as_str(), "2023"); + assert_eq!(report.sections.len(), 3); assert_eq!(xlsx.as_bytes(), XLSX_BYTES); assert_eq!(xlsx.content_type(), OFFICIAL_XLSX); assert_eq!(xlsx.content_disposition(), Some(DISPOSITION)); diff --git a/crates/libfmp/tests/statements_report_responses.rs b/crates/libfmp/tests/statements_report_responses.rs index 296e233c..f93760ab 100644 --- a/crates/libfmp/tests/statements_report_responses.rs +++ b/crates/libfmp/tests/statements_report_responses.rs @@ -32,55 +32,44 @@ fn report_dates_decode_exact_five_fields_and_keep_links_secret() { } #[test] -fn full_documented_report_preserves_all_70_top_level_keys() { +fn captured_report_is_one_object_that_preserves_every_top_level_key() { + // Trimmed from a live AAPL 2023 Q1 response captured on 2026-09-30. let source: serde_json::Value = serde_json::from_slice(REPORT).unwrap(); - assert_eq!(source.as_array().unwrap().len(), 1); - assert_eq!(source[0].as_object().unwrap().len(), 70); - - let rows: Vec = serde_json::from_value(source.clone()).unwrap(); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].symbol, Ticker::new("AAPL").unwrap()); - assert_eq!(rows[0].period, FiscalPeriod::FullYear); - assert_eq!(rows[0].year, FiscalYearString::new("2022").unwrap()); - assert_eq!(rows[0].sections.len(), 67); - - assert!(rows[0].sections.contains_key("Cover Page")); - assert!(rows[0].sections.contains_key("Shareholders' Equity")); - assert!( - rows[0] - .sections - .contains_key("CONSOLIDATED BALANCE SHEETS (Pa") - ); - assert!( - rows[0] - .sections - .contains_key("Segment Information and Geogr_6") - ); + assert_eq!(source.as_object().unwrap().len(), 6); + + let report: FinancialReportJson = serde_json::from_value(source.clone()).unwrap(); + assert_eq!(report.symbol, Ticker::new("AAPL").unwrap()); + assert_eq!(report.period, FiscalPeriod::Q1); + assert_eq!(report.year, FiscalYearString::new("2023").unwrap()); + assert_eq!(report.sections.len(), 3); + + for name in [ + "CONDENSED CONSOLIDATED BALANC_2", + "Shareholders' Equity - Addition", + "Revenue - Additional Informatio", + ] { + assert!(report.sections.contains_key(name), "missing section {name}"); + } - let round_trip = serde_json::to_value(&rows).unwrap(); + let round_trip = serde_json::to_value(&report).unwrap(); assert_eq!(round_trip, source); } #[test] -fn documented_dynamic_sections_preserve_null_nbsp_and_heterogeneous_nested_values() { - let rows: Vec = serde_json::from_slice(REPORT).unwrap(); - let sections = &rows[0].sections; +fn captured_dynamic_sections_preserve_nbsp_and_scientific_notation() { + let report: FinancialReportJson = serde_json::from_slice(REPORT).unwrap(); + let sections = &report.sections; assert_eq!( - sections["Income Taxes - Additional Infor"][0]["Income Taxes - Additional Information (Details) $ in Millions, € in Billions"] - [0], - serde_json::Value::Null - ); - assert_eq!( - sections["Cover Page"][2]["Entity Information [Line Items]"][0], + sections["Shareholders' Equity - Addition"][2]["Share Repurchase Program [Line Items]"][0], serde_json::json!("\u{a0}") ); assert_eq!( - sections["Leases - Lease Liability Maturi"][2]["2023"], - serde_json::json!([1758, "\u{a0}"]) + sections["Revenue - Additional Informatio"][2]["Total deferred revenue"], + serde_json::json!([12.6, 12.4]) ); assert_eq!( - sections["CONSOLIDATED BALANCE SHEETS (Pa"][2]["Common stock, par value (in dollars per share)"] + sections["CONDENSED CONSOLIDATED BALANC_2"][2]["Common stock, par value (in dollars per share)"] [0] .as_number() .unwrap() @@ -119,10 +108,10 @@ fn flattened_sections_round_trip_arbitrary_names_large_numbers_and_native_json_k #[test] fn serialization_rejects_every_reserved_header_in_dynamic_sections() { - let rows: Vec = serde_json::from_slice(REPORT).unwrap(); + let captured: FinancialReportJson = serde_json::from_slice(REPORT).unwrap(); for reserved in ["symbol", "period", "year"] { - let mut report = rows[0].clone(); + let mut report = captured.clone(); report.sections.insert( reserved.to_owned(), serde_json::json!("attacker-controlled replacement"), @@ -143,8 +132,8 @@ fn strict_header_wire_kinds_and_requiredness_are_enforced() { assert!(serde_json::from_value::>(string_calendar_year).is_err()); let mut numeric_report_year: serde_json::Value = serde_json::from_slice(REPORT).unwrap(); - numeric_report_year[0]["year"] = serde_json::json!(2022); - assert!(serde_json::from_value::>(numeric_report_year).is_err()); + numeric_report_year["year"] = serde_json::json!(2023); + assert!(serde_json::from_value::(numeric_report_year).is_err()); for key in ["symbol", "fiscalYear", "period", "linkJson", "linkXlsx"] { let mut missing: serde_json::Value = serde_json::from_slice(DATES).unwrap(); @@ -156,27 +145,21 @@ fn strict_header_wire_kinds_and_requiredness_are_enforced() { } for key in ["symbol", "period", "year"] { let mut missing: serde_json::Value = serde_json::from_slice(REPORT).unwrap(); - missing[0].as_object_mut().unwrap().remove(key); + missing.as_object_mut().unwrap().remove(key); assert!( - serde_json::from_value::>(missing).is_err(), + serde_json::from_value::(missing).is_err(), "accepted missing required report header {key}" ); } } #[test] -fn both_json_endpoints_preserve_bare_empty_and_multiple_arrays() { +fn dates_keep_bare_arrays_while_the_report_is_one_object() { assert!( serde_json::from_slice::>(b"[]") .unwrap() .is_empty() ); - assert!( - serde_json::from_slice::>(b"[]") - .unwrap() - .is_empty() - ); - let mut multiple_dates: serde_json::Value = serde_json::from_slice(DATES).unwrap(); let duplicate_date = multiple_dates[0].clone(); multiple_dates.as_array_mut().unwrap().push(duplicate_date); @@ -187,19 +170,11 @@ fn both_json_endpoints_preserve_bare_empty_and_multiple_arrays() { 2 ); - let mut multiple_reports: serde_json::Value = serde_json::from_slice(REPORT).unwrap(); - let duplicate_report = multiple_reports[0].clone(); - multiple_reports - .as_array_mut() - .unwrap() - .push(duplicate_report); - assert_eq!( - serde_json::from_value::>(multiple_reports) - .unwrap() - .len(), - 2 - ); - + let captured: serde_json::Value = serde_json::from_slice(REPORT).unwrap(); + assert!(serde_json::from_value::(captured.clone()).is_ok()); + assert!(serde_json::from_slice::(b"[]").is_err()); + let listed = serde_json::json!([captured]); + assert!(serde_json::from_value::(listed).is_err()); let wrapped = serde_json::json!({ "financialReports": [] }); - assert!(serde_json::from_value::>(wrapped).is_err()); + assert!(serde_json::from_value::(wrapped).is_err()); } diff --git a/sdk/go/fixtures_test.go b/sdk/go/fixtures_test.go index 0b9f540a..7c313bc9 100644 --- a/sdk/go/fixtures_test.go +++ b/sdk/go/fixtures_test.go @@ -126,6 +126,39 @@ func fixtureParity[T any](t *testing.T, name string, nullWhenOmitted, unknown [] return rows } +// assertObjectFixtureParity is assertFixtureParity for a fixture whose body +// is one bare object rather than an array: it decodes the object into T, +// requires the re-encoded member set to equal the fixture's, and proves an +// unknown member is ignored. +func assertObjectFixtureParity[T any](t *testing.T, name string) T { + t.Helper() + raw := readFixture(t, name) + + var value T + if err := json.Unmarshal(raw, &value); err != nil { + t.Fatalf("%s: decode into %T: %v", name, value, err) + } + var wire map[string]jsontext.Value + if err := json.Unmarshal(raw, &wire); err != nil { + t.Fatalf("%s: fixture is not a bare object: %v", name, err) + } + want := slices.Sorted(maps.Keys(wire)) + if got := memberSet(t, value); !slices.Equal(got, want) { + t.Fatalf("%s: re-encoded members %v, fixture members %v", name, got, want) + } + + wire["fmpUnknownMemberProbe"] = jsontext.Value(`"ignored"`) + encoded, err := json.Marshal(wire) + if err != nil { + t.Fatalf("%s: re-encode with an unknown member: %v", name, err) + } + var probe T + if err := json.Unmarshal(encoded, &probe); err != nil { + t.Fatalf("%s: unknown member was not ignored: %v", name, err) + } + return value +} + // memberValues re-encodes one model value and returns its members. func memberValues[T any](t *testing.T, row T) map[string]jsontext.Value { t.Helper() diff --git a/sdk/go/statements.go b/sdk/go/statements.go index 9f564fdb..51cfa7e9 100644 --- a/sdk/go/statements.go +++ b/sdk/go/statements.go @@ -1976,17 +1976,17 @@ func (n *StatementsReportsNamespace) Dates(ctx context.Context, q FinancialRepor return out, nil } -// JSON retrieves one dynamic JSON financial report as a bare array. +// JSON retrieves one dynamic JSON financial report as a single object. // // GET financial-reports-json?symbol=&year=&period= -func (n *StatementsReportsNamespace) JSON(ctx context.Context, q FinancialReportsJSONQuery) ([]FinancialReportJSON, error) { +func (n *StatementsReportsNamespace) JSON(ctx context.Context, q FinancialReportsJSONQuery) (FinancialReportJSON, error) { params, err := q.params() if err != nil { - return nil, err + return FinancialReportJSON{}, err } - var out []FinancialReportJSON + var out FinancialReportJSON if err := n.client.getJSON(ctx, "financial-reports-json", "financial-reports-json", params, &out); err != nil { - return nil, err + return FinancialReportJSON{}, err } return out, nil } diff --git a/sdk/go/statements_parity_test.go b/sdk/go/statements_parity_test.go index 544fdcfb..b03c8f5a 100644 --- a/sdk/go/statements_parity_test.go +++ b/sdk/go/statements_parity_test.go @@ -32,7 +32,7 @@ func TestStatementsFixturesDecodeAndReencodeToTheSameMemberSet(t *testing.T) { assertFixtureParity[AsReportedFinancialStatement](t, "cash_flow_statement_as_reported.json") assertFixtureParity[AsReportedFinancialStatement](t, "financial_statement_full_as_reported.json") assertFixtureParity[FinancialReportDate](t, "financial_reports_dates.json") - assertFixtureParity[FinancialReportJSON](t, "financial_reports_json.json") + assertObjectFixtureParity[FinancialReportJSON](t, "financial_reports_json.json") assertFixtureParity[RevenueSegmentation](t, "revenue_product_segmentation.json") assertFixtureParity[RevenueSegmentation](t, "revenue_geographic_segmentation.json") assertFixtureParity[LatestFinancialStatement](t, "latest_financial_statements.json") diff --git a/sdk/go/statements_reports_parity_test.go b/sdk/go/statements_reports_parity_test.go index 2c284398..9489cc7a 100644 --- a/sdk/go/statements_reports_parity_test.go +++ b/sdk/go/statements_reports_parity_test.go @@ -130,15 +130,17 @@ func TestDocumentedFinancialReportFixturesKeepHeadersAndDynamicSections(t *testi t.Fatalf("financial_reports_dates = %+v, want %+v", dates, want) } - reports := assertFixtureParity[FinancialReportJSON](t, "financial_reports_json.json") - if len(reports) != 1 || reports[0].Symbol != "AAPL" || reports[0].Period != "FY" || reports[0].Year != "2022" { - t.Fatalf("financial_reports_json headers = %+v", reports[0]) + // Trimmed from a live AAPL 2023 Q1 response captured on 2026-09-30: one + // bare object, not an array. + report := assertObjectFixtureParity[FinancialReportJSON](t, "financial_reports_json.json") + if report.Symbol != "AAPL" || report.Period != "Q1" || report.Year != "2023" { + t.Fatalf("financial_reports_json headers = %+v", report) } - sections := statementsObjectMembers(t, reports[0].Sections) - if len(sections) != 67 || len(memberSet(t, reports[0])) != 70 { - t.Fatalf("sections = %d, re-encoded members = %d", len(sections), len(memberSet(t, reports[0]))) + sections := statementsObjectMembers(t, report.Sections) + if len(sections) != 3 || len(memberSet(t, report)) != 6 { + t.Fatalf("sections = %d, re-encoded members = %d", len(sections), len(memberSet(t, report))) } - for _, name := range []string{"Cover Page", "Shareholders' Equity", "CONSOLIDATED BALANCE SHEETS (Pa", "Segment Information and Geogr_6"} { + for _, name := range []string{"CONDENSED CONSOLIDATED BALANC_2", "Shareholders' Equity - Addition", "Revenue - Additional Informatio"} { if _, ok := sections[name]; !ok { t.Fatalf("section %q is missing", name) } @@ -148,23 +150,21 @@ func TestDocumentedFinancialReportFixturesKeepHeadersAndDynamicSections(t *testi t.Fatalf("header %q leaked into the sections", reserved) } } - // Null, NBSP, heterogeneous arrays, and scientific notation survive as raw text. - var taxes []map[string][]jsontext.Value - if err := json.Unmarshal(sections["Income Taxes - Additional Infor"], &taxes); err != nil || string(taxes[0]["Income Taxes - Additional Information (Details) $ in Millions, € in Billions"][0]) != "null" { - t.Fatalf("income taxes section = %v, %v", taxes, err) - } - var cover []map[string][]string - if err := json.Unmarshal(sections["Cover Page"], &cover); err != nil || cover[2]["Entity Information [Line Items]"][0] != "\u00a0" { - t.Fatalf("cover page section = %v, %v", cover, err) - } - var leases []map[string][]jsontext.Value + // NBSP, fractional values, and scientific notation survive as raw text; + // null survives in the inline empty-report probe below. + var equity []map[string][]jsontext.Value var nbsp string - if err := json.Unmarshal(sections["Leases - Lease Liability Maturi"], &leases); err != nil || len(leases[2]["2023"]) != 2 || - string(leases[2]["2023"][0]) != "1758" || json.Unmarshal(leases[2]["2023"][1],  ) != nil || nbsp != "\u00a0" { - t.Fatalf("leases section = %v, %v", leases, err) + if err := json.Unmarshal(sections["Shareholders' Equity - Addition"], &equity); err != nil || + json.Unmarshal(equity[2]["Share Repurchase Program [Line Items]"][0],  ) != nil || nbsp != "\u00a0" { + t.Fatalf("shareholders' equity section = %v, %v", equity, err) + } + var revenue []map[string][]jsontext.Value + if err := json.Unmarshal(sections["Revenue - Additional Informatio"], &revenue); err != nil || len(revenue[2]["Total deferred revenue"]) != 2 || + string(revenue[2]["Total deferred revenue"][0]) != "12.6" { + t.Fatalf("revenue section = %v, %v", revenue, err) } var balance []map[string][]jsontext.Value - if err := json.Unmarshal(sections["CONSOLIDATED BALANCE SHEETS (Pa"], &balance); err != nil || string(balance[2]["Common stock, par value (in dollars per share)"][0]) != "1e-05" { + if err := json.Unmarshal(sections["CONDENSED CONSOLIDATED BALANC_2"], &balance); err != nil || string(balance[2]["Common stock, par value (in dollars per share)"][0]) != "1e-05" { t.Fatalf("balance sheet section = %v, %v", balance, err) } @@ -172,23 +172,30 @@ func TestDocumentedFinancialReportFixturesKeepHeadersAndDynamicSections(t *testi // serializer rejects reserved keys; a missing or numeric header is a // decode error; a report with no sections holds an empty object. for _, reserved := range []string{"symbol", "period", "year"} { - clash := reports[0] + clash := report clash.Sections = jsontext.Value(`{"` + reserved + `":"attacker-controlled replacement"}`) if _, err := json.Marshal(clash); err == nil || !strings.Contains(err.Error(), reserved) { t.Fatalf("reserved key %q: marshal error = %v", reserved, err) } } - var rows []FinancialReportJSON - if err := json.Unmarshal([]byte(`[{"symbol":"AAPL","period":"FY","Cover Page":[]}]`), &rows); err == nil || + var decoded FinancialReportJSON + if err := json.Unmarshal([]byte(`{"symbol":"AAPL","period":"FY","Cover Page":[]}`), &decoded); err == nil || !strings.Contains(err.Error(), `"year"`) { t.Fatalf("missing year: error = %v", err) } - if err := json.Unmarshal(statementsWithMember(t, "financial_reports_json.json", "year", "2022"), &rows); err == nil { + if err := json.Unmarshal([]byte(`{"symbol":"AAPL","period":"Q1","year":2023}`), &decoded); err == nil { t.Fatal("numeric year decoded into a string") } - if err := json.Unmarshal([]byte(`[{"symbol":"TEST","period":"Q1","year":"0007"}]`), &rows); err != nil || - string(rows[0].Sections) != "{}" || len(memberSet(t, rows[0])) != 3 { - t.Fatalf("empty report = %+v, %v", rows, err) + if err := json.Unmarshal([]byte(`[{"symbol":"AAPL","period":"Q1","year":"2023"}]`), &decoded); err == nil { + t.Fatal("a bare array decoded into one report") + } + if err := json.Unmarshal([]byte(`{"symbol":"TEST","period":"Q1","year":"0007"}`), &decoded); err != nil || + string(decoded.Sections) != "{}" || len(memberSet(t, decoded)) != 3 { + t.Fatalf("empty report = %+v, %v", decoded, err) + } + if err := json.Unmarshal([]byte(`{"symbol":"TEST","period":"Q1","year":"0007","S":[null]}`), &decoded); err != nil || + string(decoded.Sections) != `{"S":[null]}` { + t.Fatalf("null in a section = %+v, %v", decoded, err) } } diff --git a/sdk/go/statements_test.go b/sdk/go/statements_test.go index 5d9d8956..4a5121d8 100644 --- a/sdk/go/statements_test.go +++ b/sdk/go/statements_test.go @@ -127,8 +127,10 @@ func TestStatementsMethodsUseExactPathsAndWireOrder(t *testing.T) { statementsCheck(t, "AsReported.Full", asFull, err, func(r AsReportedFinancialStatement) bool { return r.Data.Kind() == '{' }) dates, err := s.Reports.Dates(ctx, NewFinancialReportsDatesQuery(statementsTicker)) statementsCheck(t, "Reports.Dates", dates, err, func(r FinancialReportDate) bool { return r.FiscalYear == 2026 }) - reports, err := s.Reports.JSON(ctx, NewFinancialReportsJSONQuery(statementsTicker, 2022, FiscalPeriodQ3)) - statementsCheck(t, "Reports.JSON", reports, err, func(r FinancialReportJSON) bool { return r.Year == "2022" }) + report, err := s.Reports.JSON(ctx, NewFinancialReportsJSONQuery(statementsTicker, 2022, FiscalPeriodQ3)) + if err != nil || report.Year != "2023" { + t.Fatalf("Reports.JSON = %+v, %v", report, err) + } xlsx, err := s.Reports.Xlsx(ctx, NewFinancialReportsXlsxQuery(statementsTicker, 2022, FiscalPeriodFullYear)) if err != nil || string(xlsx.Data) != statementsXlsxBytes || xlsx.ContentType != statementsXlsxType || xlsx.ContentDisposition != statementsDispositon || xlsx.MediaType() != statementsXlsxType { From c78ece3849727a72aa64c75149305e4560ca59af Mon Sep 17 00:00:00 2001 From: benbenbang Date: Wed, 30 Sep 2026 15:50:48 +0800 Subject: [PATCH 3/5] build(fmp-py/artifact): regenerate report stub return type Update the generated type stub for the dynamic JSON financial report to return a single `FinancialReportJson` object instead of a bare array, along with the matching docstring. --- .../fmp-py/python/fmp/_native/statements/reports/__init__.pyi | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 356e31b0..d75a38eb 100644 --- a/crates/fmp-py/python/fmp/_native/statements/reports/__init__.pyi +++ b/crates/fmp-py/python/fmp/_native/statements/reports/__init__.pyi @@ -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( From 895186612d1f39cff0a25bc77718a51a89de9a2f Mon Sep 17 00:00:00 2001 From: benbenbang Date: Wed, 30 Sep 2026 16:03:09 +0800 Subject: [PATCH 4/5] test(fmp-py): cover reports.json single-object decode Update the `reports.json` test to assert the endpoint returns one typed `FinancialReportJson` object rather than a list, checking symbol, period, year, and section contents. Add a test confirming that a bare array body is rejected with `FmpDecodeError` instead of silently taking the first row. Refresh the endpoint coverage docs and generator note to describe `statements.reports.json` as returning one typed object (not a list) whose `sections` are dynamic JSON. close #374 --- .../fmp-py/tests/test_statements_reports.py | 26 ++++++++++++------- docs/endpoint-coverage.md | 2 +- scripts/gen-endpoint-coverage.py | 2 +- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/crates/fmp-py/tests/test_statements_reports.py b/crates/fmp-py/tests/test_statements_reports.py index bb5de6f2..dd251270 100644 --- a/crates/fmp-py/tests/test_statements_reports.py +++ b/crates/fmp-py/tests/test_statements_reports.py @@ -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: diff --git a/docs/endpoint-coverage.md b/docs/endpoint-coverage.md index a3b9666e..2cc6ef5c 100644 --- a/docs/endpoint-coverage.md +++ b/docs/endpoint-coverage.md @@ -141,7 +141,7 @@ served by the single method that owns the path: | `statements` | `statements.as_reported.cash_flow` | `cash_flow_statement_as_reported` | supported | supported | | | `statements` | `statements.as_reported.full` | `financial_statement_full_as_reported` | supported | supported | | | `statements` | `statements.reports.dates` | `financial_reports_dates` | supported | supported | rows carry secret download links; redacted in Python `repr` | -| `statements` | `statements.reports.json` | `financial_reports_json` | supported | supported | typed row whose `sections` are dynamic JSON | +| `statements` | `statements.reports.json` | `financial_reports_json` | supported | supported | one typed object (not a list) whose `sections` are dynamic JSON | | `statements` | `statements.reports.xlsx` | `financial_reports_xlsx` | supported | supported | binary payload (`BinaryResponse` / `fmp.BinaryPayload`) | | `statements` | `statements.segmentation.revenue_product` | `revenue_product_segmentation` | supported | supported | | | `statements` | `statements.segmentation.revenue_geographic` | `revenue_geographic_segmentation` | supported | supported | | diff --git a/scripts/gen-endpoint-coverage.py b/scripts/gen-endpoint-coverage.py index 8405388c..c3b4828b 100755 --- a/scripts/gen-endpoint-coverage.py +++ b/scripts/gen-endpoint-coverage.py @@ -90,7 +90,7 @@ } REALTIME = "Nasdaq data is 15-minute delayed unless the real-time user declaration is on file" NOTES: dict[str, str] = { - "statements.reports.json": "typed row whose `sections` are dynamic JSON", + "statements.reports.json": "one typed object (not a list) whose `sections` are dynamic JSON", "statements.reports.xlsx": "binary payload (`BinaryResponse` / `fmp.BinaryPayload`)", "statements.reports.dates": "rows carry secret download links; redacted in Python `repr`", "quote.full": REALTIME, From f01e65288041827e0064fd6f2d83f339b25d6a4f Mon Sep 17 00:00:00 2001 From: benbenbang Date: Wed, 30 Sep 2026 16:07:26 +0800 Subject: [PATCH 5/5] test(fmp-py-gen): cover single flag return shape validation Add a `single_mismatch` fixture and registry test that assert the `single` flag must match the client return shape, reporting errors when a rows-returning method sets `single = true` and when a single-model method omits it. Also extend the scan test to verify that only `financial_reports_json` is classified as a `Single` return among the recovered methods. --- crates/fmp-py-gen/src/registry/scan.rs | 7 ++++++ .../fixtures/single_mismatch/statements.toml | 23 +++++++++++++++++++ crates/fmp-py-gen/tests/registry.rs | 18 +++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 crates/fmp-py-gen/tests/fixtures/single_mismatch/statements.toml diff --git a/crates/fmp-py-gen/src/registry/scan.rs b/crates/fmp-py-gen/src/registry/scan.rs index dcce5680..3f26ee1e 100644 --- a/crates/fmp-py-gen/src/registry/scan.rs +++ b/crates/fmp-py-gen/src/registry/scan.rs @@ -448,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] diff --git a/crates/fmp-py-gen/tests/fixtures/single_mismatch/statements.toml b/crates/fmp-py-gen/tests/fixtures/single_mismatch/statements.toml new file mode 100644 index 00000000..8daf31aa --- /dev/null +++ b/crates/fmp-py-gen/tests/fixtures/single_mismatch/statements.toml @@ -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" }, +] diff --git a/crates/fmp-py-gen/tests/registry.rs b/crates/fmp-py-gen/tests/registry.rs index f93e6376..e0e0ee77 100644 --- a/crates/fmp-py-gen/tests/registry.rs +++ b/crates/fmp-py-gen/tests/registry.rs @@ -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"));