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
1 change: 1 addition & 0 deletions crates/fmp-py-gen/src/bin/gen_go/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,7 @@ fn scalar(ident: &str) -> Option<&'static str> {
| "ChartTimeframe" | "EconomicIndicator" => "string",
"NumberOrNumericString" | "PercentageValue" => "NumberOrString",
"Date" => "Date",
"DateOrYear" => "DateOrYear",
"UsDate" => "USDate",
"ApiDateTime" => "DateTime",
"UnixSeconds" => "UnixSeconds",
Expand Down
7 changes: 7 additions & 0 deletions crates/fmp-py-gen/src/bin/gen_models/classify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ pub(crate) fn classify(ident: &str, registry: &Registry, depth: usize) -> Class
transform: Transform::IntoInner,
};
}
if ident == "DateOrYear" {
let path = "crate::models::convert::DateOrYear".to_string();
return Class::Scalar {
model_ty: path.clone(),
transform: Transform::Nested(path),
};
}
if ident == "ApiDateTime" {
return Class::Scalar {
model_ty: "::chrono::NaiveDateTime".to_string(),
Expand Down
4 changes: 2 additions & 2 deletions crates/fmp-py/python/fmp/_native/calendar/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ class IpoProspectus:
@property
def filing_date(self) -> datetime.date: ...
@property
def ipo_date(self) -> datetime.date: ...
def ipo_date(self) -> datetime.date | builtins.int: ...
@property
def cik(self) -> builtins.str: ...
@property
Expand Down Expand Up @@ -373,7 +373,7 @@ class IpoProspectus:
symbol: builtins.str,
accepted_date: datetime.date,
filing_date: datetime.date,
ipo_date: datetime.date,
ipo_date: datetime.date | builtins.int,
cik: builtins.str,
price_public_per_share: builtins.float,
price_public_total: builtins.float,
Expand Down
6 changes: 3 additions & 3 deletions crates/fmp-py/src/models/calendar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -828,7 +828,7 @@ pub(crate) struct IpoProspectus {
#[pyo3(get)]
pub filing_date: ::chrono::NaiveDate,
#[pyo3(get)]
pub ipo_date: ::chrono::NaiveDate,
pub ipo_date: crate::models::convert::DateOrYear,
#[pyo3(get)]
pub cik: String,
#[pyo3(get)]
Expand Down Expand Up @@ -860,7 +860,7 @@ impl IpoProspectus {
symbol: String,
accepted_date: ::chrono::NaiveDate,
filing_date: ::chrono::NaiveDate,
ipo_date: ::chrono::NaiveDate,
ipo_date: crate::models::convert::DateOrYear,
cik: String,
price_public_per_share: f64,
price_public_total: f64,
Expand Down Expand Up @@ -1100,7 +1100,7 @@ impl From<libfmp::responses::calendar::IpoProspectus> for IpoProspectus {
symbol: value.symbol.into_inner(),
accepted_date: value.accepted_date.into_inner(),
filing_date: value.filing_date.into_inner(),
ipo_date: value.ipo_date.into_inner(),
ipo_date: crate::models::convert::DateOrYear::from(value.ipo_date),
cik: value.cik.into_inner(),
price_public_per_share: value.price_public_per_share,
price_public_total: value.price_public_total,
Expand Down
28 changes: 28 additions & 0 deletions crates/fmp-py/src/models/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,3 +243,31 @@ mod tests {
});
}
}

/// A `datetime.date | int` model member: a full date stays a `datetime.date`
/// and a bare year the provider sent instead stays an `int`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoPyObject, FromPyObject)]
pub(crate) enum DateOrYear {
#[pyo3(transparent, annotation = "datetime.date")]
Date(chrono::NaiveDate),
#[pyo3(transparent, annotation = "int")]
Year(u16),
}

pyo3_stub_gen::impl_stub_type!(DateOrYear = chrono::NaiveDate | u16);

impl From<libfmp::codecs::DateOrYear> for DateOrYear {
fn from(value: libfmp::codecs::DateOrYear) -> Self {
match (value.as_date(), value.as_year()) {
(Some(date), _) => Self::Date(date.into_inner()),
(None, Some(year)) => Self::Year(year),
(None, None) => unreachable!("libfmp DateOrYear holds a date or a year"),
}
}
}

impl DictValue for DateOrYear {
fn dict_value<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
(*self).into_bound_py_any(py)
}
}
21 changes: 21 additions & 0 deletions crates/fmp-py/tests/test_calendar_b.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"""

import datetime
import pickle
from types import SimpleNamespace
from typing import Any

Expand Down Expand Up @@ -111,6 +112,26 @@ def test_ipos_null_exchange_and_discounts_are_none(client: Any, fixture_server:
assert row.discounts_and_commissions_total is None


def test_ipos_prospectus_year_only_ipo_date_is_an_int(
client: Any, fixture_server: FixtureServer, errors: SimpleNamespace
) -> None:
"""Issue #379: a bare ``"2020"`` ``ipoDate`` decodes as the ``int`` year, a full date stays a date."""
prospectus = load_fixture("ipos_prospectus.json")
prospectus[0]["ipoDate"] = "2020"
fixture_server.route("/ipos-prospectus", prospectus)
row = client.calendar.ipos_prospectus()[0]
assert row.ipo_date == 2020
assert type(row.ipo_date) is int
assert row.to_dict()["ipo_date"] == 2020
assert pickle.loads(pickle.dumps(row)) == row

prospectus[0]["ipoDate"] = "20x0"
fixture_server.route("/ipos-prospectus", prospectus)
with pytest.raises(errors.FmpDecodeError) as raised:
client.calendar.ipos_prospectus()
assert (raised.value.decode_path, raised.value.decode_kind) == ("[0].ipoDate", "invalid_value")


def test_ipos_prospectus_with_both_dates(client: Any, fixture_server: FixtureServer) -> None:
"""``ipos_prospectus`` encodes both ISO strings in ``from``, ``to`` order."""
fixture_server.route("/ipos-prospectus", load_fixture("ipos_prospectus.json"))
Expand Down
1 change: 1 addition & 0 deletions crates/fmp-py/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ def _sample(annotation: str, module: str) -> Any:
NUMBER: 2.5,
f"{NUMBER} | None": 3,
"datetime.date": datetime.date(2024, 1, 2),
"datetime.date | builtins.int": 2020,
"datetime.datetime": datetime.datetime(2024, 1, 2, 3, 4, 5),
}
if annotation in simple:
Expand Down
3 changes: 2 additions & 1 deletion crates/fmp-py/tests/typecheck_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,8 @@ def check_calendar_contract(client: FmpClient) -> None:
eps_estimated: float | None = earnings[0].eps_estimated
ipos: list[IpoCalendarEvent] = calendar.ipos_calendar(from_="2026-03-06", to=datetime.date(2026, 6, 6))
ipo_date: datetime.date = ipos[0].date
_ = (yield_, declared, eps_actual, eps_estimated, ipo_date)
prospectus_ipo_date: datetime.date | int = calendar.ipos_prospectus()[0].ipo_date
_ = (yield_, declared, eps_actual, eps_estimated, ipo_date, prospectus_ipo_date)


def check_bulk_contract(client: FmpClient) -> None:
Expand Down
71 changes: 71 additions & 0 deletions crates/libfmp/src/codecs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,77 @@ impl<'de> Deserialize<'de> for DateOrDateTime {
}
}

/// A field documented as a `YYYY-MM-DD` date that the provider sometimes sends
/// as a bare `YYYY` year. A year is kept as a year, never widened to a date.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum DateOrYear {
Date(Date),
Year(u16),
}

impl DateOrYear {
pub const fn as_date(self) -> Option<Date> {
match self {
Self::Date(value) => Some(value),
Self::Year(_) => None,
}
}

pub const fn as_year(self) -> Option<u16> {
match self {
Self::Date(_) => None,
Self::Year(value) => Some(value),
}
}
}

impl fmt::Display for DateOrYear {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Date(value) => value.fmt(formatter),
Self::Year(value) => write!(formatter, "{value:04}"),
}
}
}

impl FromStr for DateOrYear {
type Err = InvalidTemporalValue;

fn from_str(value: &str) -> Result<Self, Self::Err> {
if value.len() == 10 {
Date::parse(value).map(Self::Date)
} else if value.len() == 4 && value.bytes().all(|byte| byte.is_ascii_digit()) {
value
.parse()
.map(Self::Year)
.map_err(|_| InvalidTemporalValue::new("YYYY-MM-DD date or YYYY year"))
} else {
Err(InvalidTemporalValue::new("YYYY-MM-DD date or YYYY year"))
}
}
}

impl Serialize for DateOrYear {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.collect_str(self)
}
}

impl<'de> Deserialize<'de> for DateOrYear {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
String::deserialize(deserializer)?
.parse()
.map_err(de::Error::custom)
}
}

/// A US-formatted date represented exactly as `MM-DD-YYYY`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct UsDate(NaiveDate);
Expand Down
4 changes: 2 additions & 2 deletions crates/libfmp/src/responses/calendar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
use serde::{Deserialize, Deserializer, Serialize};

use crate::{
codecs::{DynamicJson, IsoTimestamp, empty_or_null_date},
codecs::{DateOrYear, DynamicJson, IsoTimestamp, empty_or_null_date},
types::{Cik, Date, ExchangeCode, MarketValue, Percentage, Price, SplitTerm, Ticker},
};

Expand Down Expand Up @@ -96,7 +96,7 @@ pub struct IpoProspectus {
pub symbol: Ticker,
pub accepted_date: Date,
pub filing_date: Date,
pub ipo_date: Date,
pub ipo_date: DateOrYear,
pub cik: Cik,
pub price_public_per_share: Price,
#[serde(serialize_with = "crate::codecs::integral_f64::serialize")]
Expand Down
23 changes: 21 additions & 2 deletions crates/libfmp/tests/calendar_responses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ mod assert_row;
use std::str::FromStr;

use libfmp::{
codecs::IsoTimestamp,
codecs::{DateOrYear, IsoTimestamp},
responses::calendar::{
DividendEvent, EarningsEvent, IpoCalendarEvent, IpoDisclosure, IpoProspectus,
StockSplitEvent,
Expand Down Expand Up @@ -164,7 +164,7 @@ fn exact_ipo_prospectus_fixture_decodes_all_thirteen_fields() {
symbol: Ticker::new("FTW-WT").unwrap(),
accepted_date: Date::from_str("2026-07-29").unwrap(),
filing_date: Date::from_str("2026-07-30").unwrap(),
ipo_date: Date::from_str("2026-07-28").unwrap(),
ipo_date: DateOrYear::Date(Date::from_str("2026-07-28").unwrap()),
cik: Cik::new("0002083125").unwrap(),
price_public_per_share: 1.0,
price_public_total: 434.0,
Expand All @@ -178,6 +178,25 @@ fn exact_ipo_prospectus_fixture_decodes_all_thirteen_fields() {
);
}

#[test]
fn ipo_prospectus_year_only_ipo_date_decodes_as_a_year() {
let mut rows: serde_json::Value = serde_json::from_slice(IPOS_PROSPECTUS).unwrap();
rows[0]["ipoDate"] = "2020".into();
let decoded: Vec<IpoProspectus> = serde_json::from_value(rows.clone()).unwrap();
assert_eq!(decoded[0].ipo_date, DateOrYear::Year(2020));
assert_eq!(decoded[0].ipo_date.as_date(), None);
assert_eq!(
serde_json::to_value(&decoded).unwrap()[0]["ipoDate"],
"2020"
);

rows[0]["ipoDate"] = "20x0".into();
let message = serde_json::from_value::<Vec<IpoProspectus>>(rows)
.unwrap_err()
.to_string();
assert!(!message.contains("20x0"), "error echoes the value");
}

#[test]
fn both_exact_stock_split_fixtures_share_the_five_field_integer_row() {
assert_field_count(STOCK_SPLITS, 5);
Expand Down
31 changes: 30 additions & 1 deletion crates/libfmp/tests/shared_wire_codecs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use std::str::FromStr;

use libfmp::{
codecs::{
DateOrDateTime, DynamicJson, DynamicObject, FiscalYear, IsoTimestamp,
DateOrDateTime, DateOrYear, DynamicJson, DynamicObject, FiscalYear, IsoTimestamp,
NumberOrNumericString, OpaqueDateText, PercentageValue, TitleCaseBoolFlag, TrueFalseFlag,
UsDate, WireBool, YesNoFlag, YnFlag, empty_date, empty_or_null_date,
empty_or_null_date_or_datetime,
Expand Down Expand Up @@ -116,6 +116,35 @@ fn temporal_contracts_are_strict_and_empty_or_null_aware() {
));
assert!(DateOrDateTime::from_str("2026-07-30T00:00:00Z").is_err());

let year: DateOrYear = serde_json::from_str(r#""2020""#).unwrap();
assert_eq!(year, DateOrYear::Year(2020));
assert_eq!((year.as_year(), year.as_date()), (Some(2020), None));
assert_eq!(serde_json::to_string(&year).unwrap(), r#""2020""#);
let date: DateOrYear = serde_json::from_str(r#""2026-07-28""#).unwrap();
assert_eq!(date, DateOrYear::Date(Date::parse("2026-07-28").unwrap()));
assert_eq!(date.as_year(), None);
assert_eq!(serde_json::to_string(&date).unwrap(), r#""2026-07-28""#);
for garbage in [
r#""20x0""#,
r#""20201""#,
r#""202""#,
r#""2026-13-01""#,
r#""2026-07-28 00:00:00""#,
r#"" 2020""#,
r#""""#,
] {
let message = serde_json::from_str::<DateOrYear>(garbage)
.unwrap_err()
.to_string();
let inner = garbage.trim_matches('"');
assert!(
inner.is_empty() || !message.contains(inner),
"{garbage}: error echoes the value"
);
}
assert!(serde_json::from_str::<DateOrYear>("2020").is_err());
assert!(serde_json::from_str::<DateOrYear>("null").is_err());

for wire in [
r#"{"declaration_date":"","filing_date":null}"#,
r#"{"declaration_date":null,"filing_date":""}"#,
Expand Down
3 changes: 2 additions & 1 deletion docs/adr/0002-shared-wire-types-and-python-conversions.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ The Python binding applies this conversion policy to public endpoint models
offset spelling and millisecond precision are preserved rather than
normalised into a timezone-aware `datetime.datetime`. Date-or-datetime
fields remain `str`, preserving which of the two documented forms was
received. Empty and null optional dates become `None`. Opaque human or
received. Date-or-year fields (ADR 0034) become `datetime.date | int`.
Empty and null optional dates become `None`. Opaque human or
partial date text remains `str`.
- Dynamic JSON recursively becomes native Python dictionaries, lists, strings,
integers, floats, booleans, and `None`.
Expand Down
1 change: 1 addition & 0 deletions docs/adr/0030-go-sdk-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ that no wire information is invented or lost:
| `Date` (`YYYY-MM-DD`) | `fmp.Date` | civil date, no zone, own type |
| `UsDate` (`MM-DD-YYYY`) | `fmp.UsDate` | same civil date, US wire text; `.Date()` converts |
| `ApiDateTime` (naive) | `fmp.DateTime` | naive, own type; not `time.Time` |
| `DateOrYear` (`YYYY-MM-DD` or `YYYY`) | `fmp.DateOrYear` | own type; `.Date()` / `.Year()` report which form arrived (ADR 0034) |
| `IsoTimestamp` | `string` | exact RFC 3339 text, offset and precision preserved |
| `DateOrDateTime`, `OpaqueDateText` | `string` | which form arrived is preserved |
| `UnixSeconds`, `UnixMilliseconds` | `fmp.UnixSeconds`, `fmp.UnixMilliseconds` (`int64`) | `.Time()` helpers |
Expand Down
Loading
Loading