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-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..3f26ee1e 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(), } } @@ -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] 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/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 bee2aab7..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")); @@ -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" }, @@ -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", 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`. 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( 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/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/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/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, 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 {