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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions crates/fmp-py-gen/src/bin/gen_go/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ fn render_model(model: &ModelPlan, out: &mut String) {
}
Codec::RequiredOption
| Codec::RequiredNumber
| Codec::Count
| Codec::EmptyDate
| Codec::EmptyOrNullDate => local_name(rust_name),
};
Expand Down Expand Up @@ -310,6 +311,7 @@ fn render_required_switch(model: &ModelPlan, out: &mut String) {
_ if field.raw_key => format!("{} == nil", wire_local(rust_name)),
Codec::RequiredOption
| Codec::RequiredNumber
| Codec::Count
| Codec::EmptyDate
| Codec::EmptyOrNullDate
| Codec::DynamicJson => format!("len(shadow.{}) == 0", field.name),
Expand Down Expand Up @@ -361,6 +363,14 @@ fn render_codec_block(model: &str, field: &GoField, local: &str, out: &mut Strin
field.wire, field.name
);
}
Codec::Count => {
let _ = writeln!(
out,
"\t{local}, err := decodeCount({model:?}, {:?}, shadow.{})\n\
\tif err != nil {{\n\t\treturn err\n\t}}",
field.wire, field.name
);
}
Codec::DynamicObject => render_kind_guard(model, field, '{', "object", out),
Codec::Number => render_kind_guard(model, field, '0', "number", out),
Codec::RequiredNumber => {
Expand Down
48 changes: 48 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 @@ -600,6 +600,54 @@ fn codec_fields_map_to_the_shadow_shapes_of_the_adr() {
);
}

#[test]
fn count_codec_decodes_through_the_raw_shadow_and_keeps_the_public_uint64() {
let aliases: BTreeMap<String, String> = [("Count".to_string(), "u64".to_string())].into();
let structs = Vec::new();
let table = TypeTable::new(&structs, &aliases);
let codec = "crate::codecs::count::deserialize";
let def = row(vec![
field("strong_buy", "Count", deserialize_with(codec)),
field("hold", "Count", FieldAttrs::default()),
]);
let mapped = table.go_field(&def, &def.fields[0]).expect("maps");
assert_eq!(
(
mapped.public_ty.as_str(),
mapped.shadow_ty.as_str(),
mapped.codec,
mapped.required_key(),
),
("uint64", "jsontext.Value", Codec::Count, true)
);
let models = plan_models("test", &[&def], &table).expect("plans");
let rendered = render_models("test", &models);
for expected in [
"StrongBuy uint64 `json:\"strongBuy\"`",
"StrongBuy jsontext.Value `json:\"strongBuy\"`",
"case len(shadow.StrongBuy) == 0:",
"strongBuy, err := decodeCount(\"Row\", \"strongBuy\", shadow.StrongBuy)",
"StrongBuy: strongBuy,",
"Hold *uint64 `json:\"hold\"`",
"Hold: *shadow.Hold,",
] {
assert!(
rendered.contains(expected),
"missing {expected:?} in\n{rendered}"
);
}

for (name, ty) in [
("maybe", "Option<Count>"),
("price", "f64"),
("many", "Vec<Count>"),
] {
let def = row(vec![field(name, ty, deserialize_with(codec))]);
let error = table.go_field(&def, &def.fields[0]).expect_err(name);
assert!(error.contains("bare Count"), "{name}: {error}");
}
}

#[test]
fn skip_serializing_if_none_marks_only_the_public_tag_omitzero() {
let aliases = BTreeMap::new();
Expand Down
18 changes: 18 additions & 0 deletions crates/fmp-py-gen/src/bin/gen_go/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ pub(crate) enum Codec {
/// `deserialize_with = "required_option"` on `Option<Number>`: the key
/// must be present, null is allowed, a present value must be a number.
RequiredNumber,
/// `deserialize_with = "crate::codecs::count::deserialize"` on a bare
/// `Count`: the key is required, null is rejected, and an integral float
/// such as `3.0` decodes as the integer. The shadow keeps the raw value.
Count,
/// A `DynamicObject` that holds every member the named fields do not
/// claim, as serde `flatten` on a map does: `#[serde(flatten)]`, or the
/// single `DynamicObject` field of a struct with a hand-written
Expand All @@ -40,6 +44,9 @@ pub(crate) enum Codec {
Embedded,
}

/// The `deserialize_with` path of the Rust count codec.
const COUNT_CODEC: &str = "crate::codecs::count::deserialize";

/// One Go struct member derived from a Rust field.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct GoField {
Expand Down Expand Up @@ -72,6 +79,7 @@ impl GoField {
Codec::Embedded => false,
Codec::RequiredOption
| Codec::RequiredNumber
| Codec::Count
| Codec::EmptyDate
| Codec::EmptyOrNullDate
| Codec::DynamicJson
Expand Down Expand Up @@ -171,6 +179,16 @@ impl<'a> TypeTable<'a> {
(Some("required_option"), BaseKind::Number, [Wrap::Option]) => {
(Codec::RequiredNumber, "jsontext.Value".to_string())
}
(Some(COUNT_CODEC), BaseKind::Scalar, []) if base.go == "uint64" => {
(Codec::Count, "jsontext.Value".to_string())
}
(Some(COUNT_CODEC), _, _) => {
return Err(fail(
"the count codec needs a bare Count (u64) field; Option<Count> has no Go \
shape yet"
.to_string(),
));
}
(Some("required_option"), _, _) => {
return Err(fail(
"required_option needs an Option<scalar, Number, or DynamicJson> field"
Expand Down
19 changes: 19 additions & 0 deletions crates/fmp-py/tests/test_analyst.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,25 @@ def test_stock_grades_summary_takes_one_symbol(client: Any, fixture_server: Fixt
assert row.consensus == "Buy"


def test_stock_grades_summary_accepts_an_integral_float_count(client: Any, fixture_server: FixtureServer) -> None:
"""A count sent as an integral float such as ``3.0`` decodes to the ``int`` ``3``."""
fixture_server.route("/grades-consensus", [{**load_fixture("stock_grades_summary.json")[0], "buy": 3.0}])
rows = client.analyst.stock_grades_summary("AAPL")

assert rows[0].buy == 3
assert type(rows[0].buy) is int


def test_stock_grades_summary_rejects_a_fractional_count(
client: Any, fixture_server: FixtureServer, errors: SimpleNamespace
) -> None:
"""A fractional count such as ``2.9`` maps to ``FmpDecodeError`` with the endpoint id."""
fixture_server.route("/grades-consensus", [{**load_fixture("stock_grades_summary.json")[0], "buy": 2.9}])
with pytest.raises(errors.FmpDecodeError) as raised:
client.analyst.stock_grades_summary("AAPL")
assert raised.value.endpoint == "grades-consensus"


def test_empty_array_decodes_to_an_empty_list(client: Any, fixture_server: FixtureServer) -> None:
"""A bare ``[]`` body is a valid, empty result."""
fixture_server.route("/grades", [])
Expand Down
128 changes: 127 additions & 1 deletion crates/libfmp/src/codecs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -748,11 +748,137 @@ pub(crate) mod integral_f64 {
}
}

/// Deserializers for response fields typed with [`Count`](crate::types::Count).
///
/// The provider documents counts as JSON integers but has sent an integral
/// float such as `3.0`, so a count decodes from a non-negative JSON integer
/// or from a finite integral JSON float below `2^64`. A fractional, negative,
/// non-finite or out-of-range number, and any non-number, is a decode error
/// whose message never includes the value. Apply [`deserialize`] with
/// `deserialize_with` on a bare `Count` field and [`deserialize_option`] on an
/// `Option<Count>` (with `default` when the key may be missing);
/// serialization is serde's default `u64` path.
pub(crate) mod count {
use super::*;

const EXPECTED: &str = "a count must be a non-negative integral JSON number below 2^64";

fn from_number<E: de::Error>(number: &Number) -> Result<u64, E> {
if let Some(value) = number.as_u64() {
return Ok(value);
}
match number.as_f64() {
Some(value)
if value.is_finite()
&& value.fract() == 0.0
&& value >= 0.0
&& value < u64::MAX as f64 =>
{
Ok(value as u64)
}
_ => Err(E::custom(EXPECTED)),
}
}

pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<u64, D::Error>
where
D: Deserializer<'de>,
{
from_number(&Number::deserialize(deserializer)?)
}

#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn deserialize_option<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
D: Deserializer<'de>,
{
Option::<Number>::deserialize(deserializer)?
.map(|number| from_number(&number))
.transpose()
}
}

#[cfg(test)]
mod tests {
use serde::{Deserialize, Serialize};

use crate::types::Volume;
use crate::types::{Count, Volume};

#[test]
fn count_accepts_integers_and_integral_floats_on_both_decode_paths() {
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct Row {
#[serde(deserialize_with = "super::count::deserialize")]
buy: Count,
}
let from_text = |text: &str| {
serde_json::from_str::<Row>(&format!(r#"{{"buy":{text}}}"#)).map(|row| row.buy)
};
let from_value = |text: &str| {
let value: serde_json::Value =
serde_json::from_str(&format!(r#"{{"buy":{text}}}"#)).unwrap();
serde_json::from_value::<Row>(value).map(|row| row.buy)
};

for (text, expected) in [
("3", 3),
("3.0", 3),
("0", 0),
("0.0", 0),
("-0.0", 0),
("1e2", 100),
("18446744073709551615", u64::MAX),
("18446744073709549568.0", 18_446_744_073_709_549_568),
] {
assert_eq!(from_text(text).unwrap(), expected, "from_str {text}");
assert_eq!(from_value(text).unwrap(), expected, "from_value {text}");
}
for text in [
"2.9",
"-1",
"-1.0",
"18446744073709551616",
"18446744073709551616.0",
"1e20",
"null",
r#""3""#,
"true",
] {
let error = from_text(text).unwrap_err();
assert!(from_value(text).is_err(), "from_value {text}");
if text.len() > 2 && text.starts_with(['1', '2', '-']) {
assert!(
!error.to_string().contains(text.trim_start_matches('-')),
"the error must not echo the value: {error}"
);
}
}
assert_eq!(
serde_json::to_string(&Row { buy: 3 }).unwrap(),
r#"{"buy":3}"#
);
}

#[test]
fn count_option_accepts_null_and_integral_floats() {
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct Row {
#[serde(default, deserialize_with = "super::count::deserialize_option")]
buy: Option<Count>,
}
let decode = |text: &str| serde_json::from_str::<Row>(text).map(|row| row.buy);

assert_eq!(decode(r#"{"buy":3.0}"#).unwrap(), Some(3));
assert_eq!(decode(r#"{"buy":7}"#).unwrap(), Some(7));
assert_eq!(decode(r#"{"buy":null}"#).unwrap(), None);
assert_eq!(decode("{}").unwrap(), None);
assert!(decode(r#"{"buy":2.9}"#).is_err());
assert!(decode(r#"{"buy":-1}"#).is_err());
assert_eq!(
serde_json::to_string(&Row { buy: Some(3) }).unwrap(),
r#"{"buy":3}"#
);
}

#[test]
fn integral_f64_accepts_any_json_number_and_re_encodes_integral_values_as_integers() {
Expand Down
30 changes: 30 additions & 0 deletions crates/libfmp/src/responses/analyst.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ pub struct FinancialEstimate {
pub eps_avg: f64,
pub eps_high: f64,
pub eps_low: f64,
#[serde(deserialize_with = "crate::codecs::count::deserialize")]
pub num_analysts_revenue: Count,
#[serde(deserialize_with = "crate::codecs::count::deserialize")]
pub num_analysts_eps: Count,
}

Expand All @@ -55,12 +57,19 @@ pub struct FinancialEstimate {
pub struct RatingSnapshot {
pub symbol: Ticker,
pub rating: String,
#[serde(deserialize_with = "crate::codecs::count::deserialize")]
pub overall_score: Count,
#[serde(deserialize_with = "crate::codecs::count::deserialize")]
pub discounted_cash_flow_score: Count,
#[serde(deserialize_with = "crate::codecs::count::deserialize")]
pub return_on_equity_score: Count,
#[serde(deserialize_with = "crate::codecs::count::deserialize")]
pub return_on_assets_score: Count,
#[serde(deserialize_with = "crate::codecs::count::deserialize")]
pub debt_to_equity_score: Count,
#[serde(deserialize_with = "crate::codecs::count::deserialize")]
pub price_to_earnings_score: Count,
#[serde(deserialize_with = "crate::codecs::count::deserialize")]
pub price_to_book_score: Count,
}

Expand All @@ -72,12 +81,19 @@ pub struct HistoricalRating {
pub symbol: Ticker,
pub date: Date,
pub rating: String,
#[serde(deserialize_with = "crate::codecs::count::deserialize")]
pub overall_score: Count,
#[serde(deserialize_with = "crate::codecs::count::deserialize")]
pub discounted_cash_flow_score: Count,
#[serde(deserialize_with = "crate::codecs::count::deserialize")]
pub return_on_equity_score: Count,
#[serde(deserialize_with = "crate::codecs::count::deserialize")]
pub return_on_assets_score: Count,
#[serde(deserialize_with = "crate::codecs::count::deserialize")]
pub debt_to_equity_score: Count,
#[serde(deserialize_with = "crate::codecs::count::deserialize")]
pub price_to_earnings_score: Count,
#[serde(deserialize_with = "crate::codecs::count::deserialize")]
pub price_to_book_score: Count,
}

Expand All @@ -87,12 +103,16 @@ pub struct HistoricalRating {
#[serde(rename_all = "camelCase")]
pub struct PriceTargetSummary {
pub symbol: Ticker,
#[serde(deserialize_with = "crate::codecs::count::deserialize")]
pub last_month_count: Count,
pub last_month_avg_price_target: Price,
#[serde(deserialize_with = "crate::codecs::count::deserialize")]
pub last_quarter_count: Count,
pub last_quarter_avg_price_target: Price,
#[serde(deserialize_with = "crate::codecs::count::deserialize")]
pub last_year_count: Count,
pub last_year_avg_price_target: Price,
#[serde(deserialize_with = "crate::codecs::count::deserialize")]
pub all_time_count: Count,
pub all_time_avg_price_target: Price,
/// The exact provider string, whose contents happen to be JSON-array text.
Expand Down Expand Up @@ -131,10 +151,15 @@ pub struct StockGrade {
pub struct HistoricalStockGrade {
pub symbol: Ticker,
pub date: Date,
#[serde(deserialize_with = "crate::codecs::count::deserialize")]
pub analyst_ratings_strong_buy: Count,
#[serde(deserialize_with = "crate::codecs::count::deserialize")]
pub analyst_ratings_buy: Count,
#[serde(deserialize_with = "crate::codecs::count::deserialize")]
pub analyst_ratings_hold: Count,
#[serde(deserialize_with = "crate::codecs::count::deserialize")]
pub analyst_ratings_sell: Count,
#[serde(deserialize_with = "crate::codecs::count::deserialize")]
pub analyst_ratings_strong_sell: Count,
}

Expand All @@ -144,10 +169,15 @@ pub struct HistoricalStockGrade {
#[serde(rename_all = "camelCase")]
pub struct StockGradesSummary {
pub symbol: Ticker,
#[serde(deserialize_with = "crate::codecs::count::deserialize")]
pub strong_buy: Count,
#[serde(deserialize_with = "crate::codecs::count::deserialize")]
pub buy: Count,
#[serde(deserialize_with = "crate::codecs::count::deserialize")]
pub hold: Count,
#[serde(deserialize_with = "crate::codecs::count::deserialize")]
pub sell: Count,
#[serde(deserialize_with = "crate::codecs::count::deserialize")]
pub strong_sell: Count,
pub consensus: String,
}
Loading
Loading