Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
376f1e6
fix(libfmp): add CSV response contract and row decoder
benbenbang Oct 1, 2026
8d31f2b
fix(libfmp): raise CSV bulk body limit to 256 MiB default
benbenbang Oct 1, 2026
28dbb71
fix(sdk/go): handle text/csv bulk responses with larger body limit
benbenbang Oct 1, 2026
3bb640c
fix(fmp-py-gen): resolve CSV contract in wire resolver and gen_go
benbenbang Oct 1, 2026
9224ff0
fix(sdk/go): require every model member to be a CSV header column
benbenbang Oct 1, 2026
dec4f27
fix(libfmp): require every CSV row member to be a header column
benbenbang Oct 1, 2026
caba107
test(libfmp): add shared bulk CSV test helpers
benbenbang Oct 1, 2026
a34c6f2
fix(bulk)!: decode snapshot bulk routes as text/csv
benbenbang Oct 1, 2026
ca7e5ed
test(libfmp): rewrite bulk snapshot suite over live CSV fixtures
benbenbang Oct 1, 2026
c5a3efb
test(sdk/go): decode bulk parity fixtures from shared live CSV
benbenbang Oct 1, 2026
bc5da48
test(fmp-py): serve live CSV fixtures for bulk snapshot tests
benbenbang Oct 1, 2026
88af353
fix(bulk)!: decode metric bulk routes as CSV
benbenbang Oct 1, 2026
2493f59
test(libfmp): rewrite bulk metric suite over live CSV fixtures
benbenbang Oct 1, 2026
7334634
test(sdk/go): decode metric bulk parity from CSV fixtures
benbenbang Oct 1, 2026
063f04a
test(fmp-py): serve live CSV fixtures for bulk metric route tests
benbenbang Oct 1, 2026
2c440e9
fix(bulk)!: decode final seven bulk routes as text/csv
benbenbang Oct 1, 2026
9da203e
test(libfmp): rewrite bulk income suite over live CSV fixtures
benbenbang Oct 1, 2026
fe3e29e
test(libfmp): rewrite bulk balance response suite over live CSV fixtures
benbenbang Oct 1, 2026
1d9031a
test(libfmp): rewrite bulk cash and eod suite over live CSV fixtures
benbenbang Oct 1, 2026
2ca4f94
test(sdk/go): decode statement and eod bulk parity from CSV fixtures
benbenbang Oct 1, 2026
b8b90f2
test(fmp-py): serve CSV fixtures for bulk statement and eod tests
benbenbang Oct 1, 2026
bf3a171
fix(bulk)!: make empty numeric snapshot cells optional per ADR 0035
benbenbang Oct 1, 2026
e337b0f
fix(bulk)!: make numeric metric members optional for empty cells
benbenbang Oct 1, 2026
b00597d
fix(bulk)!: make bulk income numeric members optional
benbenbang Oct 1, 2026
d21e088
fix(bulk)!: make balance-sheet numeric members optional
benbenbang Oct 1, 2026
f7e5a54
fix(bulk)!: make numeric cash-flow and EOD members optional
benbenbang Oct 1, 2026
71d2062
fix(company)!: make profile isin, country and change optional
benbenbang Oct 1, 2026
596e902
test(fmp-py): expect str | None for optional bulk numeric members
benbenbang Oct 1, 2026
08bae14
test(fmp-py): decode empty and header-only csv bulk bodies to no rows
benbenbang Oct 1, 2026
e7da4cf
docs(adr): add ADR 0035 for bulk CSV responses
benbenbang Oct 1, 2026
362c805
fix(csv): strip leading UTF-8 BOM before the Go CSV header
benbenbang Oct 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Carthage/Build/

# Data
*.csv
!crates/libfmp/tests/fixtures/*.csv
*.h5
*.hdf5
*.pickle
Expand Down
28 changes: 28 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ license = "MIT"
[workspace.dependencies]
bytes = "1.12.1"
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
csv = "1.4"
http = "1.5.0"
libc = "0.2.189"
libfmp = { path = "crates/libfmp" }
Expand Down
9 changes: 7 additions & 2 deletions crates/fmp-py-gen/src/bin/gen_go/methods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ impl QueryPlan {
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ResponseKind {
Rows(String),
CsvRows(String),
Single(String),
Dynamic,
Binary(Vec<String>),
Expand Down Expand Up @@ -270,7 +271,9 @@ impl Context<'_> {
let response = match (&endpoint.response_model, &wire.contract) {
(_, Contract::Binary(types)) if endpoint.binary => ResponseKind::Binary(types.clone()),
(None, Contract::Rows) if endpoint.dynamic => ResponseKind::Dynamic,
(Some(model), Contract::Rows) => {
(Some(model), contract @ (Contract::Rows | Contract::CsvRows))
if !(endpoint.single && *contract == Contract::CsvRows) =>
{
let owner = self.model_owner.get(&model.name).ok_or_else(|| {
format!("response model `{}` is not a discovered struct", model.name)
})?;
Expand All @@ -281,7 +284,9 @@ impl Context<'_> {
model.name
));
}
if endpoint.single {
if *contract == Contract::CsvRows {
ResponseKind::CsvRows(go_name(&model.name))
} else if endpoint.single {
ResponseKind::Single(go_name(&model.name))
} else {
ResponseKind::Rows(go_name(&model.name))
Expand Down
10 changes: 9 additions & 1 deletion crates/fmp-py-gen/src/bin/gen_go/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,9 @@ 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".to_string()),
ResponseKind::Rows(model) | ResponseKind::CsvRows(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()),
Expand Down Expand Up @@ -368,6 +370,12 @@ fn render_method(namespace: &NamespacePlan, method: &MethodPlan, out: &mut Strin
types.join(", ")
);
}
ResponseKind::CsvRows(model) => {
let _ = writeln!(
out,
"\treturn getCSV[{model}](ctx, n.client, {id:?}, {path:?}, {params})\n}}\n"
);
}
ResponseKind::Rows(_) | ResponseKind::Single(_) | ResponseKind::Dynamic => {
let _ = writeln!(
out,
Expand Down
24 changes: 24 additions & 0 deletions crates/fmp-py-gen/src/bin/gen_go/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,30 @@ fn nested_namespaces_embed_children_by_registry_path() {
);
}

/// Bulk descriptors use `EndpointSpec::get_csv`, so their methods decode
/// through `getCSV` instead of `getJSON` (ADR 0035).
#[test]
fn csv_bulk_endpoints_render_through_get_csv() {
let files =
generate_domains(&domain_set(&["bulk", "company"])).expect("bulk generates with company");
let (_, bulk) = files
.iter()
.find(|(name, _)| name == "bulk.go")
.expect("bulk.go is rendered");
assert!(
bulk.contains(
"return getCSV[BulkStockRating](ctx, n.client, \"rating-bulk\", \"rating-bulk\", nil)"
),
"{bulk}"
);
assert!(
bulk.contains(
"return getCSV[CompanyProfile](ctx, n.client, \"profile-bulk\", \"profile-bulk\", params)"
),
"{bulk}"
);
}

#[test]
fn cross_domain_models_require_their_owner_to_be_generated() {
let error =
Expand Down
19 changes: 18 additions & 1 deletion crates/fmp-py-gen/src/registry/wire/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ impl fmt::Display for HttpMethod {
pub enum Contract {
/// A JSON array of rows.
Rows,
/// A headed CSV body, one row per record (ADR 0035).
CsvRows,
/// A binary body with the listed acceptable content types.
Binary(Vec<String>),
}
Expand Down Expand Up @@ -273,7 +275,8 @@ mod tests {
assert!(!wire.id.is_empty(), "{}", wire.method);
assert_eq!(wire.http_method, HttpMethod::Get, "{}", wire.method);
match (&wire.contract, endpoint.binary) {
(Contract::Rows, false) | (Contract::Binary(_), true) => {}
(Contract::Rows | Contract::CsvRows, false)
| (Contract::Binary(_), true) => {}
(contract, binary) => {
panic!(
"{}: contract {contract:?} vs binary = {binary}",
Expand Down Expand Up @@ -510,11 +513,19 @@ mod tests {
pub fn explicit(query: CustomQuery) -> EndpointSpec<CustomQuery, BinaryResponse> {
EndpointSpec::with_response(HttpMethod::Get, "x", "x-path", query, ResponseContract::binary(&["a/b"]))
}
pub fn bulk() -> EndpointSpec<(), Vec<Row>> {
EndpointSpec::get_csv("dcf-bulk", "dcf-bulk", ())
}
pub fn explicit_csv() -> EndpointSpec<(), Vec<Row>> {
EndpointSpec::with_response(HttpMethod::Get, "y", "y-path", (), ResponseContract::csv())
}
impl Client {
pub async fn quotes(&self) -> Result<Vec<Row>> { self.execute(&quotes()).await }
pub async fn custom(&self, query: CustomQuery) -> Result<Vec<Row>> { self.execute(&custom(query)).await }
pub async fn xlsx(&self, query: CustomQuery) -> Result<BinaryResponse> { self.execute(&xlsx(query)).await }
pub async fn explicit(&self, query: CustomQuery) -> Result<BinaryResponse> { self.execute(&explicit(query)).await }
pub async fn bulk(&self) -> Result<Vec<Row>> { self.execute(&bulk()).await }
pub async fn explicit_csv(&self) -> Result<Vec<Row>> { self.execute(&explicit_csv()).await }
}
"#;
let surface = surface(&[("dcf.rs", source)]);
Expand Down Expand Up @@ -548,6 +559,12 @@ mod tests {
Contract::Binary(vec!["a/b".to_owned()])
);
assert_eq!(surface.endpoints["explicit"].relative_path, "x-path");
assert_eq!(surface.endpoints["bulk"].contract, Contract::CsvRows);
assert_eq!(surface.endpoints["bulk"].relative_path, "dcf-bulk");
assert_eq!(
surface.endpoints["explicit_csv"].contract,
Contract::CsvRows
);
}

#[test]
Expand Down
14 changes: 12 additions & 2 deletions crates/fmp-py-gen/src/registry/wire/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,13 @@ impl Evaluator<'_> {
contract: Contract::Binary(strings(3, "content types")?),
metadata: None,
}),
"get_csv" => Ok(Spec {
id: string(0, "id")?,
relative_path: string(1, "path")?,
http_method: HttpMethod::Get,
contract: Contract::CsvRows,
metadata: None,
}),
"new" => Ok(Spec {
http_method: http_method(0)?,
id: string(1, "id")?,
Expand All @@ -315,15 +322,15 @@ impl Evaluator<'_> {
}
}

/// Reads `ResponseContract::json()` or `ResponseContract::binary(list)`.
/// Reads `ResponseContract::json()`, `::csv()`, or `::binary(list)`.
fn response_contract(
&self,
expr: Option<&Expr>,
module: &ModulePath,
) -> Result<Contract, String> {
let unsupported = |expr: Option<&Expr>| {
format!(
"response contract is not `ResponseContract::json()` or `::binary(..)`: {}",
"response contract is not `ResponseContract::json()`, `::csv()` or `::binary(..)`: {}",
expr.map(text).unwrap_or_default()
)
};
Expand All @@ -343,6 +350,9 @@ impl Evaluator<'_> {
([head, kind], None) if head == "ResponseContract" && kind == "json" => {
Ok(Contract::Rows)
}
([head, kind], None) if head == "ResponseContract" && kind == "csv" => {
Ok(Contract::CsvRows)
}
([head, kind], Some(list)) if head == "ResponseContract" && kind == "binary" => {
match self.eval(list, &BTreeMap::new(), module) {
Value::Strs(values) => Ok(Contract::Binary(values)),
Expand Down
4 changes: 3 additions & 1 deletion crates/fmp-py/python/fmp/_native/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,9 @@ class FmpClient:
the default host the constructor raises `FmpConfigError` naming
`FMP_API_KEY`.
`timeout` and `connect_timeout` are positive finite numbers of seconds.
`max_response_body_bytes` bounds each buffered response. Authenticated
`max_response_body_bytes` bounds each buffered response; when omitted,
`client.bulk` methods buffer up to 256 MiB and every other method up to
64 MiB. Authenticated
non-loopback HTTP requires `danger_allow_insecure_authentication=True`.
Redirects are either disabled or restricted to the same origin.

Expand Down
Loading
Loading