diff --git a/python/pydantic_core/_pydantic_core.pyi b/python/pydantic_core/_pydantic_core.pyi index 0e29a6b41..dceddbf99 100644 --- a/python/pydantic_core/_pydantic_core.pyi +++ b/python/pydantic_core/_pydantic_core.pyi @@ -402,6 +402,7 @@ def to_json( exclude_none: bool = False, round_trip: bool = False, timedelta_mode: Literal['iso8601', 'float'] = 'iso8601', + temporal_mode: Literal['iso8601', 'seconds', 'milliseconds'] = 'iso8601', bytes_mode: Literal['utf8', 'base64', 'hex'] = 'utf8', inf_nan_mode: Literal['null', 'constants', 'strings'] = 'constants', serialize_unknown: bool = False, @@ -425,6 +426,9 @@ def to_json( exclude_none: Whether to exclude fields that have a value of `None`. round_trip: Whether to enable serialization and validation round-trip support. timedelta_mode: How to serialize `timedelta` objects, either `'iso8601'` or `'float'`. + temporal_mode: How to serialize datetime-like objects (`datetime`, `date`, `time`), either `'iso8601'`, `'seconds'`, or `'milliseconds'`. + `iso8601` returns an ISO 8601 string; `seconds` returns the Unix timestamp in seconds as a float; `milliseconds` returns the Unix timestamp in milliseconds as a float. + bytes_mode: How to serialize `bytes` objects, either `'utf8'`, `'base64'`, or `'hex'`. inf_nan_mode: How to serialize `Infinity`, `-Infinity` and `NaN` values, either `'null'`, `'constants'`, or `'strings'`. serialize_unknown: Attempt to serialize unknown types, `str(value)` will be used, if that fails @@ -483,6 +487,7 @@ def to_jsonable_python( exclude_none: bool = False, round_trip: bool = False, timedelta_mode: Literal['iso8601', 'float'] = 'iso8601', + temporal_mode: Literal['iso8601', 'seconds', 'milliseconds'] = 'iso8601', bytes_mode: Literal['utf8', 'base64', 'hex'] = 'utf8', inf_nan_mode: Literal['null', 'constants', 'strings'] = 'constants', serialize_unknown: bool = False, @@ -504,6 +509,9 @@ def to_jsonable_python( exclude_none: Whether to exclude fields that have a value of `None`. round_trip: Whether to enable serialization and validation round-trip support. timedelta_mode: How to serialize `timedelta` objects, either `'iso8601'` or `'float'`. + temporal_mode: How to serialize datetime-like objects (`datetime`, `date`, `time`), either `'iso8601'`, `'seconds'`, or `'milliseconds'`. + `iso8601` returns an ISO 8601 string; `seconds` returns the Unix timestamp in seconds as a float; `milliseconds` returns the Unix timestamp in milliseconds as a float. + bytes_mode: How to serialize `bytes` objects, either `'utf8'`, `'base64'`, or `'hex'`. inf_nan_mode: How to serialize `Infinity`, `-Infinity` and `NaN` values, either `'null'`, `'constants'`, or `'strings'`. serialize_unknown: Attempt to serialize unknown types, `str(value)` will be used, if that fails diff --git a/python/pydantic_core/core_schema.py b/python/pydantic_core/core_schema.py index 803569244..26b73c087 100644 --- a/python/pydantic_core/core_schema.py +++ b/python/pydantic_core/core_schema.py @@ -61,6 +61,10 @@ class CoreConfig(TypedDict, total=False): str_to_upper: Whether to convert string fields to uppercase. allow_inf_nan: Whether to allow infinity and NaN values for float fields. Default is `True`. ser_json_timedelta: The serialization option for `timedelta` values. Default is 'iso8601'. + Note that if ser_json_temporal is set, then this param will be ignored. + ser_json_temporal: The serialization option for datetime like values. Default is 'iso8601'. + The types this covers are datetime, date, time and timedelta. + If this is set, it will take precedence over ser_json_timedelta ser_json_bytes: The serialization option for `bytes` values. Default is 'utf8'. ser_json_inf_nan: The serialization option for infinity and NaN values in float fields. Default is 'null'. @@ -102,6 +106,7 @@ class CoreConfig(TypedDict, total=False): allow_inf_nan: bool # default: True # the config options are used to customise serialization to JSON ser_json_timedelta: Literal['iso8601', 'float'] # default: 'iso8601' + ser_json_temporal: Literal['iso8601', 'seconds', 'milliseconds'] # default: 'iso8601' ser_json_bytes: Literal['utf8', 'base64', 'hex'] # default: 'utf8' ser_json_inf_nan: Literal['null', 'constants', 'strings'] # default: 'null' val_json_bytes: Literal['utf8', 'base64', 'hex'] # default: 'utf8' diff --git a/src/errors/validation_exception.rs b/src/errors/validation_exception.rs index 5d36851c2..d4e40ebd9 100644 --- a/src/errors/validation_exception.rs +++ b/src/errors/validation_exception.rs @@ -340,7 +340,7 @@ impl ValidationError { include_context: bool, include_input: bool, ) -> PyResult> { - let state = SerializationState::new("iso8601", "utf8", "constants")?; + let state = SerializationState::new("iso8601", "iso8601", "utf8", "constants")?; let extra = state.extra(py, &SerMode::Json, None, false, false, true, None, false, None); let serializer = ValidationErrorSerializer { py, diff --git a/src/input/datetime.rs b/src/input/datetime.rs index 279e79159..590d859b3 100644 --- a/src/input/datetime.rs +++ b/src/input/datetime.rs @@ -131,6 +131,93 @@ impl EitherTimedelta<'_> { Self::PySubclass(py_timedelta) => pytimedelta_subclass_as_duration(py_timedelta), } } + + pub fn total_seconds(&self) -> PyResult { + match self { + Self::Raw(timedelta) => { + let mut days: i64 = i64::from(timedelta.day); + let mut seconds: i64 = i64::from(timedelta.second); + let mut microseconds = i64::from(timedelta.microsecond); + if !timedelta.positive { + days = -days; + seconds = -seconds; + microseconds = -microseconds; + } + + let days_seconds = (86_400 * days) + seconds; + if let Some(days_seconds_as_micros) = days_seconds.checked_mul(1_000_000) { + let total_microseconds = days_seconds_as_micros + microseconds; + Ok(total_microseconds as f64 / 1_000_000.0) + } else { + // Fall back to floating-point operations if the multiplication overflows + let total_seconds = days_seconds as f64 + microseconds as f64 / 1_000_000.0; + Ok(total_seconds) + } + } + Self::PyExact(py_timedelta) => { + let days: i64 = py_timedelta.get_days().into(); // -999999999 to 999999999 + let seconds: i64 = py_timedelta.get_seconds().into(); // 0 through 86399 + let microseconds = py_timedelta.get_microseconds(); // 0 through 999999 + let days_seconds = (86_400 * days) + seconds; + if let Some(days_seconds_as_micros) = days_seconds.checked_mul(1_000_000) { + let total_microseconds = days_seconds_as_micros + i64::from(microseconds); + Ok(total_microseconds as f64 / 1_000_000.0) + } else { + // Fall back to floating-point operations if the multiplication overflows + let total_seconds = days_seconds as f64 + f64::from(microseconds) / 1_000_000.0; + Ok(total_seconds) + } + } + Self::PySubclass(py_timedelta) => py_timedelta + .call_method0(intern!(py_timedelta.py(), "total_seconds"))? + .extract(), + } + } + + pub fn total_milliseconds(&self) -> PyResult { + match self { + Self::Raw(timedelta) => { + let mut days: i64 = i64::from(timedelta.day); + let mut seconds: i64 = i64::from(timedelta.second); + let mut microseconds = i64::from(timedelta.microsecond); + if !timedelta.positive { + days = -days; + seconds = -seconds; + microseconds = -microseconds; + } + + let days_seconds = (86_400 * days) + seconds; + if let Some(days_seconds_as_micros) = days_seconds.checked_mul(1_000_000) { + let total_microseconds = days_seconds_as_micros + microseconds; + Ok(total_microseconds as f64 / 1_000.0) + } else { + // Fall back to floating-point operations if the multiplication overflows + let total_seconds = days_seconds as f64 + microseconds as f64 / 1_000.0; + Ok(total_seconds) + } + } + Self::PyExact(py_timedelta) => { + let days: i64 = py_timedelta.get_days().into(); // -999999999 to 999999999 + let seconds: i64 = py_timedelta.get_seconds().into(); // 0 through 86399 + let microseconds = py_timedelta.get_microseconds(); // 0 through 999999 + let days_seconds = (86_400 * days) + seconds; + if let Some(days_seconds_as_micros) = days_seconds.checked_mul(1_000_000) { + let total_microseconds = days_seconds_as_micros + i64::from(microseconds); + Ok(total_microseconds as f64 / 1_000.0) + } else { + // Fall back to floating-point operations if the multiplication overflows + let total_milliseconds = days_seconds as f64 * 1_000.0 + f64::from(microseconds) / 1_000.0; + Ok(total_milliseconds) + } + } + Self::PySubclass(py_timedelta) => { + let total_seconds: f64 = py_timedelta + .call_method0(intern!(py_timedelta.py(), "total_seconds"))? + .extract()?; + Ok(total_seconds / 1000.0) + } + } + } } impl<'py> TryFrom<&'_ Bound<'py, PyAny>> for EitherTimedelta<'py> { diff --git a/src/serializers/config.rs b/src/serializers/config.rs index e2cf85a53..61e5b0697 100644 --- a/src/serializers/config.rs +++ b/src/serializers/config.rs @@ -3,13 +3,17 @@ use std::str::{from_utf8, FromStr, Utf8Error}; use base64::Engine; use pyo3::prelude::*; -use pyo3::types::{PyDelta, PyDict, PyString}; +use pyo3::types::{PyDate, PyDateTime, PyDict, PyString, PyTime}; use pyo3::{intern, IntoPyObjectExt}; use serde::ser::Error; use crate::build_tools::py_schema_err; use crate::input::EitherTimedelta; +use crate::serializers::type_serializers::datetime_etc::{ + date_to_milliseconds, date_to_seconds, date_to_string, datetime_to_milliseconds, datetime_to_seconds, + datetime_to_string, time_to_milliseconds, time_to_seconds, time_to_string, +}; use crate::tools::SchemaDict; use super::errors::py_err_se_err; @@ -17,26 +21,43 @@ use super::errors::py_err_se_err; #[derive(Debug, Clone)] #[allow(clippy::struct_field_names)] pub(crate) struct SerializationConfig { - pub timedelta_mode: TimedeltaMode, + pub temporal_mode: TemporalMode, pub bytes_mode: BytesMode, pub inf_nan_mode: InfNanMode, } impl SerializationConfig { pub fn from_config(config: Option<&Bound<'_, PyDict>>) -> PyResult { - let timedelta_mode = TimedeltaMode::from_config(config)?; + let temporal_set = config + .and_then(|cfg| cfg.contains(intern!(cfg.py(), "ser_json_temporal")).ok()) + .unwrap_or(false); + let temporal_mode = if temporal_set { + TemporalMode::from_config(config)? + } else { + TimedeltaMode::from_config(config)?.into() + }; let bytes_mode = BytesMode::from_config(config)?; let inf_nan_mode = InfNanMode::from_config(config)?; Ok(Self { - timedelta_mode, + temporal_mode, bytes_mode, inf_nan_mode, }) } - pub fn from_args(timedelta_mode: &str, bytes_mode: &str, inf_nan_mode: &str) -> PyResult { + pub fn from_args( + timedelta_mode: &str, + temporal_mode: &str, + bytes_mode: &str, + inf_nan_mode: &str, + ) -> PyResult { + let resolved_temporal_mode = if temporal_mode != "iso8601" { + TemporalMode::from_str(temporal_mode)? + } else { + TimedeltaMode::from_str(timedelta_mode)?.into() + }; Ok(Self { - timedelta_mode: TimedeltaMode::from_str(timedelta_mode)?, + temporal_mode: resolved_temporal_mode, bytes_mode: BytesMode::from_str(bytes_mode)?, inf_nan_mode: InfNanMode::from_str(inf_nan_mode)?, }) @@ -91,6 +112,14 @@ serialization_mode! { Float => "float", } +serialization_mode! { + TemporalMode, + "ser_json_temporal", + Iso8601 => "iso8601", + Seconds => "seconds", + Milliseconds => "milliseconds" +} + serialization_mode! { BytesMode, "ser_json_bytes", @@ -107,44 +136,165 @@ serialization_mode! { Strings => "strings", } -impl TimedeltaMode { - fn total_seconds<'py>(py_timedelta: &Bound<'py, PyDelta>) -> PyResult> { - py_timedelta.call_method0(intern!(py_timedelta.py(), "total_seconds")) +impl TimedeltaMode {} + +impl From for TemporalMode { + fn from(value: TimedeltaMode) -> Self { + match value { + TimedeltaMode::Iso8601 => TemporalMode::Iso8601, + TimedeltaMode::Float => TemporalMode::Seconds, + } + } +} + +impl TemporalMode { + pub fn datetime_to_json(self, py: Python, datetime: &Bound<'_, PyDateTime>) -> PyResult { + match self { + Self::Iso8601 => datetime_to_string(datetime)?.into_py_any(py), + Self::Seconds => datetime_to_seconds(datetime)?.into_py_any(py), + Self::Milliseconds => datetime_to_milliseconds(datetime)?.into_py_any(py), + } + } + + pub fn date_to_json(self, py: Python, date: &Bound<'_, PyDate>) -> PyResult { + match self { + Self::Iso8601 => date_to_string(date)?.into_py_any(py), + Self::Seconds => date_to_seconds(date)?.into_py_any(py), + Self::Milliseconds => date_to_milliseconds(date)?.into_py_any(py), + } + } + + pub fn time_to_json(self, py: Python, time: &Bound<'_, PyTime>) -> PyResult { + match self { + Self::Iso8601 => time_to_string(time)?.into_py_any(py), + Self::Seconds => time_to_seconds(time)?.into_py_any(py), + Self::Milliseconds => time_to_milliseconds(time)?.into_py_any(py), + } } - pub fn either_delta_to_json(self, py: Python, either_delta: EitherTimedelta) -> PyResult { + pub fn timedelta_to_json(self, py: Python, either_delta: EitherTimedelta) -> PyResult { match self { Self::Iso8601 => { let d = either_delta.to_duration()?; d.to_string().into_py_any(py) } - Self::Float => { - // convert to int via a py timedelta not duration since we know this this case the input would have - // been a py timedelta - let py_timedelta = either_delta.into_pyobject(py)?; - let seconds = Self::total_seconds(&py_timedelta)?; - Ok(seconds.unbind()) + Self::Seconds => { + let seconds: f64 = either_delta.total_seconds()?; + seconds.into_py_any(py) + } + Self::Milliseconds => { + let milliseconds: f64 = either_delta.total_milliseconds()?; + milliseconds.into_py_any(py) } } } - pub fn json_key<'py>(self, py: Python, either_delta: EitherTimedelta) -> PyResult> { + pub fn datetime_json_key<'py>(self, datetime: &Bound<'_, PyDateTime>) -> PyResult> { + match self { + Self::Iso8601 => Ok(datetime_to_string(datetime)?.to_string().into()), + Self::Seconds => Ok(datetime_to_seconds(datetime)?.to_string().into()), + Self::Milliseconds => Ok(datetime_to_milliseconds(datetime)?.to_string().into()), + } + } + + pub fn date_json_key<'py>(self, date: &Bound<'_, PyDate>) -> PyResult> { + match self { + Self::Iso8601 => Ok(date_to_string(date)?.to_string().into()), + Self::Seconds => Ok(date_to_seconds(date)?.to_string().into()), + Self::Milliseconds => Ok(date_to_milliseconds(date)?.to_string().into()), + } + } + + pub fn time_json_key<'py>(self, time: &Bound<'_, PyTime>) -> PyResult> { + match self { + Self::Iso8601 => Ok(time_to_string(time)?.to_string().into()), + Self::Seconds => Ok(time_to_seconds(time)?.to_string().into()), + Self::Milliseconds => Ok(time_to_milliseconds(time)?.to_string().into()), + } + } + + pub fn timedelta_json_key<'py>(self, either_delta: &EitherTimedelta) -> PyResult> { match self { Self::Iso8601 => { let d = either_delta.to_duration()?; Ok(d.to_string().into()) } - Self::Float => { - let py_timedelta = either_delta.into_pyobject(py)?; - let seconds: f64 = Self::total_seconds(&py_timedelta)?.extract()?; + Self::Seconds => { + let seconds: f64 = either_delta.total_seconds()?; Ok(seconds.to_string().into()) } + Self::Milliseconds => { + let milliseconds: f64 = either_delta.total_milliseconds()?; + Ok(milliseconds.to_string().into()) + } + } + } + + pub fn datetime_serialize( + self, + datetime: &Bound<'_, PyDateTime>, + serializer: S, + ) -> Result { + match self { + Self::Iso8601 => { + let s = datetime_to_string(datetime).map_err(py_err_se_err)?; + serializer.serialize_str(&s) + } + Self::Seconds => { + let s = datetime_to_seconds(datetime).map_err(py_err_se_err)?; + serializer.serialize_f64(s) + } + Self::Milliseconds => { + let s = datetime_to_milliseconds(datetime).map_err(py_err_se_err)?; + serializer.serialize_f64(s) + } + } + } + + pub fn date_serialize( + self, + date: &Bound<'_, PyDate>, + serializer: S, + ) -> Result { + match self { + Self::Iso8601 => { + let s = date_to_string(date).map_err(py_err_se_err)?; + serializer.serialize_str(&s) + } + Self::Seconds => { + let s = date_to_seconds(date).map_err(py_err_se_err)?; + serializer.serialize_f64(s) + } + Self::Milliseconds => { + let s = date_to_milliseconds(date).map_err(py_err_se_err)?; + serializer.serialize_f64(s) + } + } + } + + pub fn time_serialize( + self, + time: &Bound<'_, PyTime>, + serializer: S, + ) -> Result { + match self { + Self::Iso8601 => { + let s = time_to_string(time).map_err(py_err_se_err)?; + serializer.serialize_str(&s) + } + Self::Seconds => { + let s = time_to_seconds(time).map_err(py_err_se_err)?; + serializer.serialize_f64(s) + } + Self::Milliseconds => { + let s = time_to_milliseconds(time).map_err(py_err_se_err)?; + serializer.serialize_f64(s) + } } } pub fn timedelta_serialize( self, - py: Python, either_delta: EitherTimedelta, serializer: S, ) -> Result { @@ -153,12 +303,14 @@ impl TimedeltaMode { let d = either_delta.to_duration().map_err(py_err_se_err)?; serializer.serialize_str(&d.to_string()) } - Self::Float => { - let py_timedelta = either_delta.into_pyobject(py).map_err(py_err_se_err)?; - let seconds = Self::total_seconds(&py_timedelta).map_err(py_err_se_err)?; - let seconds: f64 = seconds.extract().map_err(py_err_se_err)?; + Self::Seconds => { + let seconds: f64 = either_delta.total_seconds().map_err(py_err_se_err)?; serializer.serialize_f64(seconds) } + Self::Milliseconds => { + let milliseconds: f64 = either_delta.total_milliseconds().map_err(py_err_se_err)?; + serializer.serialize_f64(milliseconds) + } } } } diff --git a/src/serializers/extra.rs b/src/serializers/extra.rs index e919beb8c..0b1036bd5 100644 --- a/src/serializers/extra.rs +++ b/src/serializers/extra.rs @@ -28,10 +28,10 @@ pub(crate) struct SerializationState { } impl SerializationState { - pub fn new(timedelta_mode: &str, bytes_mode: &str, inf_nan_mode: &str) -> PyResult { + pub fn new(timedelta_mode: &str, temporal_mode: &str, bytes_mode: &str, inf_nan_mode: &str) -> PyResult { let warnings = CollectWarnings::new(WarningsMode::None); let rec_guard = SerRecursionState::default(); - let config = SerializationConfig::from_args(timedelta_mode, bytes_mode, inf_nan_mode)?; + let config = SerializationConfig::from_args(timedelta_mode, temporal_mode, bytes_mode, inf_nan_mode)?; Ok(Self { warnings, rec_guard, diff --git a/src/serializers/infer.rs b/src/serializers/infer.rs index eb067b81d..285c2f99a 100644 --- a/src/serializers/infer.rs +++ b/src/serializers/infer.rs @@ -176,23 +176,23 @@ pub(crate) fn infer_to_python_known( })? } ObType::Datetime => { - let iso_dt = super::type_serializers::datetime_etc::datetime_to_string(value.downcast()?)?; - iso_dt.into_py_any(py)? + let datetime = extra + .config + .temporal_mode + .datetime_to_json(value.py(), value.downcast()?)?; + datetime.into_py_any(py)? } ObType::Date => { - let iso_date = super::type_serializers::datetime_etc::date_to_string(value.downcast()?)?; - iso_date.into_py_any(py)? + let date = extra.config.temporal_mode.date_to_json(value.py(), value.downcast()?)?; + date.into_py_any(py)? } ObType::Time => { - let iso_time = super::type_serializers::datetime_etc::time_to_string(value.downcast()?)?; - iso_time.into_py_any(py)? + let time = extra.config.temporal_mode.time_to_json(value.py(), value.downcast()?)?; + time.into_py_any(py)? } ObType::Timedelta => { let either_delta = EitherTimedelta::try_from(value)?; - extra - .config - .timedelta_mode - .either_delta_to_json(value.py(), either_delta)? + extra.config.temporal_mode.timedelta_to_json(value.py(), either_delta)? } ObType::Url => { let py_url: PyUrl = value.extract()?; @@ -458,26 +458,20 @@ pub(crate) fn infer_serialize_known( ObType::Set => serialize_seq!(PySet), ObType::Frozenset => serialize_seq!(PyFrozenSet), ObType::Datetime => { - let py_dt = value.downcast().map_err(py_err_se_err)?; - let iso_dt = super::type_serializers::datetime_etc::datetime_to_string(py_dt).map_err(py_err_se_err)?; - serializer.serialize_str(&iso_dt) + let py_datetime = value.downcast().map_err(py_err_se_err)?; + extra.config.temporal_mode.datetime_serialize(py_datetime, serializer) } ObType::Date => { let py_date = value.downcast().map_err(py_err_se_err)?; - let iso_date = super::type_serializers::datetime_etc::date_to_string(py_date).map_err(py_err_se_err)?; - serializer.serialize_str(&iso_date) + extra.config.temporal_mode.date_serialize(py_date, serializer) } ObType::Time => { let py_time = value.downcast().map_err(py_err_se_err)?; - let iso_time = super::type_serializers::datetime_etc::time_to_string(py_time).map_err(py_err_se_err)?; - serializer.serialize_str(&iso_time) + extra.config.temporal_mode.time_serialize(py_time, serializer) } ObType::Timedelta => { let either_delta = EitherTimedelta::try_from(value).map_err(py_err_se_err)?; - extra - .config - .timedelta_mode - .timedelta_serialize(value.py(), either_delta, serializer) + extra.config.temporal_mode.timedelta_serialize(either_delta, serializer) } ObType::Url => { let py_url: PyUrl = value.extract().map_err(py_err_se_err)?; @@ -635,25 +629,16 @@ pub(crate) fn infer_json_key_known<'a>( }) .map(|cow| Cow::Owned(cow.into_owned())) } - ObType::Datetime => { - let iso_dt = super::type_serializers::datetime_etc::datetime_to_string(key.downcast()?)?; - Ok(Cow::Owned(iso_dt)) - } - ObType::Date => { - let iso_date = super::type_serializers::datetime_etc::date_to_string(key.downcast()?)?; - Ok(Cow::Owned(iso_date)) - } - ObType::Time => { - let iso_time = super::type_serializers::datetime_etc::time_to_string(key.downcast()?)?; - Ok(Cow::Owned(iso_time)) - } + ObType::Datetime => extra.config.temporal_mode.datetime_json_key(key.downcast()?), + ObType::Date => extra.config.temporal_mode.date_json_key(key.downcast()?), + ObType::Time => extra.config.temporal_mode.time_json_key(key.downcast()?), ObType::Uuid => { let uuid = super::type_serializers::uuid::uuid_to_string(key)?; Ok(Cow::Owned(uuid)) } ObType::Timedelta => { let either_delta = EitherTimedelta::try_from(key)?; - extra.config.timedelta_mode.json_key(key.py(), either_delta) + extra.config.temporal_mode.timedelta_json_key(&either_delta) } ObType::Url => { let py_url: PyUrl = key.extract()?; diff --git a/src/serializers/mod.rs b/src/serializers/mod.rs index f9b51496f..acecaf749 100644 --- a/src/serializers/mod.rs +++ b/src/serializers/mod.rs @@ -241,9 +241,9 @@ impl SchemaSerializer { #[allow(clippy::too_many_arguments)] #[pyfunction] #[pyo3(signature = (value, *, indent = None, ensure_ascii = false, include = None, exclude = None, by_alias = true, - exclude_none = false, round_trip = false, timedelta_mode = "iso8601", bytes_mode = "utf8", - inf_nan_mode = "constants", serialize_unknown = false, fallback = None, serialize_as_any = false, - context = None))] + exclude_none = false, round_trip = false, timedelta_mode = "iso8601", temporal_mode = "iso8601", + bytes_mode = "utf8", inf_nan_mode = "constants", serialize_unknown = false, fallback = None, + serialize_as_any = false, context = None))] pub fn to_json( py: Python, value: &Bound<'_, PyAny>, @@ -255,6 +255,7 @@ pub fn to_json( exclude_none: bool, round_trip: bool, timedelta_mode: &str, + temporal_mode: &str, bytes_mode: &str, inf_nan_mode: &str, serialize_unknown: bool, @@ -262,7 +263,7 @@ pub fn to_json( serialize_as_any: bool, context: Option<&Bound<'_, PyAny>>, ) -> PyResult { - let state = SerializationState::new(timedelta_mode, bytes_mode, inf_nan_mode)?; + let state = SerializationState::new(timedelta_mode, temporal_mode, bytes_mode, inf_nan_mode)?; let extra = state.extra( py, &SerMode::Json, @@ -292,8 +293,8 @@ pub fn to_json( #[allow(clippy::too_many_arguments)] #[pyfunction] #[pyo3(signature = (value, *, include = None, exclude = None, by_alias = true, exclude_none = false, round_trip = false, - timedelta_mode = "iso8601", bytes_mode = "utf8", inf_nan_mode = "constants", serialize_unknown = false, fallback = None, - serialize_as_any = false, context = None))] + timedelta_mode = "iso8601", temporal_mode = "iso8601", bytes_mode = "utf8", inf_nan_mode = "constants", + serialize_unknown = false, fallback = None, serialize_as_any = false, context = None))] pub fn to_jsonable_python( py: Python, value: &Bound<'_, PyAny>, @@ -303,6 +304,7 @@ pub fn to_jsonable_python( exclude_none: bool, round_trip: bool, timedelta_mode: &str, + temporal_mode: &str, bytes_mode: &str, inf_nan_mode: &str, serialize_unknown: bool, @@ -310,7 +312,7 @@ pub fn to_jsonable_python( serialize_as_any: bool, context: Option<&Bound<'_, PyAny>>, ) -> PyResult { - let state = SerializationState::new(timedelta_mode, bytes_mode, inf_nan_mode)?; + let state = SerializationState::new(timedelta_mode, temporal_mode, bytes_mode, inf_nan_mode)?; let extra = state.extra( py, &SerMode::Json, diff --git a/src/serializers/type_serializers/datetime_etc.rs b/src/serializers/type_serializers/datetime_etc.rs index 0b601745a..84c712d6a 100644 --- a/src/serializers/type_serializers/datetime_etc.rs +++ b/src/serializers/type_serializers/datetime_etc.rs @@ -2,21 +2,47 @@ use std::borrow::Cow; use pyo3::prelude::*; use pyo3::types::{PyDate, PyDateTime, PyDict, PyTime}; -use pyo3::IntoPyObjectExt; +use super::{ + infer_json_key, infer_serialize, infer_to_python, BuildSerializer, CombinedSerializer, Extra, SerMode, + TypeSerializer, +}; use crate::definitions::DefinitionsBuilder; use crate::input::{pydate_as_date, pydatetime_as_datetime, pytime_as_time}; +use crate::serializers::config::{FromConfig, TemporalMode}; use crate::PydanticSerializationUnexpectedValue; -use super::{ - infer_json_key, infer_serialize, infer_to_python, py_err_se_err, BuildSerializer, CombinedSerializer, Extra, - SerMode, TypeSerializer, -}; - pub(crate) fn datetime_to_string(py_dt: &Bound<'_, PyDateTime>) -> PyResult { pydatetime_as_datetime(py_dt).map(|dt| dt.to_string()) } +pub(crate) fn datetime_to_seconds(py_dt: &Bound<'_, PyDateTime>) -> PyResult { + pydatetime_as_datetime(py_dt).map(|dt| { + dt.date.timestamp() as f64 + + f64::from(dt.time.hour) * 3600.0 + + f64::from(dt.time.minute) * 60.0 + + f64::from(dt.time.second) + + f64::from(dt.time.microsecond) / 1_000_000.0 + }) +} + +pub(crate) fn datetime_to_milliseconds(py_dt: &Bound<'_, PyDateTime>) -> PyResult { + pydatetime_as_datetime(py_dt).map(|dt| { + dt.date.timestamp_ms() as f64 + + f64::from(dt.time.hour) * 3_600_000.0 + + f64::from(dt.time.minute) * 60_000.0 + + f64::from(dt.time.second) * 1_000.0 + + f64::from(dt.time.microsecond) / 1_000.0 + }) +} + +pub(crate) fn date_to_seconds(py_date: &Bound<'_, PyDate>) -> PyResult { + pydate_as_date(py_date).map(|dt| dt.timestamp() as f64) +} +pub(crate) fn date_to_milliseconds(py_date: &Bound<'_, PyDate>) -> PyResult { + pydate_as_date(py_date).map(|dt| dt.timestamp_ms() as f64) +} + pub(crate) fn date_to_string(py_date: &Bound<'_, PyDate>) -> PyResult { pydate_as_date(py_date).map(|dt| dt.to_string()) } @@ -25,6 +51,24 @@ pub(crate) fn time_to_string(py_time: &Bound<'_, PyTime>) -> PyResult { pytime_as_time(py_time, None).map(|dt| dt.to_string()) } +pub(crate) fn time_to_seconds(py_time: &Bound<'_, PyTime>) -> PyResult { + pytime_as_time(py_time, None).map(|t| { + f64::from(t.hour) * 3600.0 + + f64::from(t.minute) * 60.0 + + f64::from(t.second) + + f64::from(t.microsecond) / 1_000_000.0 + }) +} + +pub(crate) fn time_to_milliseconds(py_time: &Bound<'_, PyTime>) -> PyResult { + pytime_as_time(py_time, None).map(|t| { + f64::from(t.hour) * 3_600_000.0 + + f64::from(t.minute) * 60_000.0 + + f64::from(t.second) * 1_000.0 + + f64::from(t.microsecond) / 1_000.0 + }) +} + fn downcast_date_reject_datetime<'a, 'py>(py_date: &'a Bound<'py, PyAny>) -> PyResult<&'a Bound<'py, PyDate>> { if let Ok(py_date) = py_date.downcast::() { // because `datetime` is a subclass of `date` we have to check that the value is not a @@ -37,26 +81,36 @@ fn downcast_date_reject_datetime<'a, 'py>(py_date: &'a Bound<'py, PyAny>) -> PyR Err(PydanticSerializationUnexpectedValue::new_from_msg(None).to_py_err()) } -macro_rules! build_serializer { - ($struct_name:ident, $expected_type:literal, $downcast:path, $convert_func:ident $(, $json_check_func:ident)?) => { +macro_rules! build_temporal_serializer { + ( + $Struct:ident, + $expected_type:literal, + $downcast:path, + $to_json:ident, + $json_key_fn:ident, + $serialize_fn:ident + ) => { #[derive(Debug)] - pub struct $struct_name; + pub struct $Struct { + temporal_mode: TemporalMode, + } - impl BuildSerializer for $struct_name { + impl BuildSerializer for $Struct { const EXPECTED_TYPE: &'static str = $expected_type; fn build( _schema: &Bound<'_, PyDict>, - _config: Option<&Bound<'_, PyDict>>, + config: Option<&Bound<'_, PyDict>>, _definitions: &mut DefinitionsBuilder, ) -> PyResult { - Ok(Self {}.into()) + let temporal_mode = TemporalMode::from_config(config)?; + Ok(Self { temporal_mode }.into()) } } - impl_py_gc_traverse!($struct_name {}); + impl_py_gc_traverse!($Struct {}); - impl TypeSerializer for $struct_name { + impl TypeSerializer for $Struct { fn to_python( &self, value: &Bound<'_, PyAny>, @@ -64,25 +118,21 @@ macro_rules! build_serializer { exclude: Option<&Bound<'_, PyAny>>, extra: &Extra, ) -> PyResult { - let py = value.py(); - match $downcast(value) { - Ok(py_value) => match extra.mode { - SerMode::Json => { - let s = $convert_func(py_value)?; - s.into_py_any(py) + match extra.mode { + SerMode::Json => match $downcast(value) { + Ok(py_value) => Ok(self.temporal_mode.$to_json(value.py(), py_value)?), + Err(_) => { + extra.warnings.on_fallback_py(self.get_name(), value, extra)?; + infer_to_python(value, include, exclude, extra) } - _ => Ok(value.clone().unbind()), }, - Err(_) => { - extra.warnings.on_fallback_py(self.get_name(), value, extra)?; - infer_to_python(value, include, exclude, extra) - } + _ => infer_to_python(value, include, exclude, extra), } } fn json_key<'a>(&self, key: &'a Bound<'_, PyAny>, extra: &Extra) -> PyResult> { match $downcast(key) { - Ok(py_value) => Ok(Cow::Owned($convert_func(py_value)?)), + Ok(py_value) => Ok(self.temporal_mode.$json_key_fn(py_value)?), Err(_) => { extra.warnings.on_fallback_py(self.get_name(), key, extra)?; infer_json_key(key, extra) @@ -99,10 +149,7 @@ macro_rules! build_serializer { extra: &Extra, ) -> Result { match $downcast(value) { - Ok(py_value) => { - let s = $convert_func(py_value).map_err(py_err_se_err)?; - serializer.serialize_str(&s) - } + Ok(py_value) => self.temporal_mode.$serialize_fn(py_value, serializer), Err(_) => { extra .warnings @@ -119,11 +166,29 @@ macro_rules! build_serializer { }; } -build_serializer!( +build_temporal_serializer!( DatetimeSerializer, "datetime", PyAnyMethods::downcast::, - datetime_to_string + datetime_to_json, + datetime_json_key, + datetime_serialize +); + +build_temporal_serializer!( + DateSerializer, + "date", + downcast_date_reject_datetime, + date_to_json, + date_json_key, + date_serialize +); + +build_temporal_serializer!( + TimeSerializer, + "time", + PyAnyMethods::downcast::, + time_to_json, + time_json_key, + time_serialize ); -build_serializer!(DateSerializer, "date", downcast_date_reject_datetime, date_to_string); -build_serializer!(TimeSerializer, "time", PyAnyMethods::downcast::, time_to_string); diff --git a/src/serializers/type_serializers/timedelta.rs b/src/serializers/type_serializers/timedelta.rs index f1fb99da7..2cac5345f 100644 --- a/src/serializers/type_serializers/timedelta.rs +++ b/src/serializers/type_serializers/timedelta.rs @@ -1,11 +1,11 @@ -use std::borrow::Cow; - +use pyo3::intern; use pyo3::prelude::*; use pyo3::types::PyDict; +use std::borrow::Cow; use crate::definitions::DefinitionsBuilder; use crate::input::EitherTimedelta; -use crate::serializers::config::{FromConfig, TimedeltaMode}; +use crate::serializers::config::{FromConfig, TemporalMode, TimedeltaMode}; use super::{ infer_json_key, infer_serialize, infer_to_python, BuildSerializer, CombinedSerializer, Extra, SerMode, @@ -14,7 +14,7 @@ use super::{ #[derive(Debug)] pub struct TimeDeltaSerializer { - timedelta_mode: TimedeltaMode, + temporal_mode: TemporalMode, } impl BuildSerializer for TimeDeltaSerializer { @@ -25,8 +25,17 @@ impl BuildSerializer for TimeDeltaSerializer { config: Option<&Bound<'_, PyDict>>, _definitions: &mut DefinitionsBuilder, ) -> PyResult { - let timedelta_mode = TimedeltaMode::from_config(config)?; - Ok(Self { timedelta_mode }.into()) + let temporal_set = config + .and_then(|cfg| cfg.contains(intern!(cfg.py(), "ser_json_temporal")).ok()) + .unwrap_or(false); + let temporal_mode = if temporal_set { + TemporalMode::from_config(config)? + } else { + let td_mode = TimedeltaMode::from_config(config)?; + td_mode.into() + }; + + Ok(Self { temporal_mode }.into()) } } @@ -42,7 +51,7 @@ impl TypeSerializer for TimeDeltaSerializer { ) -> PyResult { match extra.mode { SerMode::Json => match EitherTimedelta::try_from(value) { - Ok(either_timedelta) => self.timedelta_mode.either_delta_to_json(value.py(), either_timedelta), + Ok(either_timedelta) => Ok(self.temporal_mode.timedelta_to_json(value.py(), either_timedelta)?), Err(_) => { extra.warnings.on_fallback_py(self.get_name(), value, extra)?; infer_to_python(value, include, exclude, extra) @@ -54,7 +63,7 @@ impl TypeSerializer for TimeDeltaSerializer { fn json_key<'a>(&self, key: &'a Bound<'_, PyAny>, extra: &Extra) -> PyResult> { match EitherTimedelta::try_from(key) { - Ok(either_timedelta) => self.timedelta_mode.json_key(key.py(), either_timedelta), + Ok(either_timedelta) => self.temporal_mode.timedelta_json_key(&either_timedelta), Err(_) => { extra.warnings.on_fallback_py(self.get_name(), key, extra)?; infer_json_key(key, extra) @@ -71,9 +80,7 @@ impl TypeSerializer for TimeDeltaSerializer { extra: &Extra, ) -> Result { match EitherTimedelta::try_from(value) { - Ok(either_timedelta) => self - .timedelta_mode - .timedelta_serialize(value.py(), either_timedelta, serializer), + Ok(either_timedelta) => self.temporal_mode.timedelta_serialize(either_timedelta, serializer), Err(_) => { extra.warnings.on_fallback_ser::(self.get_name(), value, extra)?; infer_serialize(value, serializer, include, exclude, extra) diff --git a/tests/serializers/test_datetime.py b/tests/serializers/test_datetime.py index e6f71ba0c..88edb205a 100644 --- a/tests/serializers/test_datetime.py +++ b/tests/serializers/test_datetime.py @@ -113,3 +113,187 @@ def test_date_datetime_union(): assert v.to_python(datetime(2022, 12, 2, 1)) == datetime(2022, 12, 2, 1) assert v.to_python(datetime(2022, 12, 2, 1), mode='json') == '2022-12-02T01:00:00' assert v.to_json(datetime(2022, 12, 2, 1)) == b'"2022-12-02T01:00:00"' + + +@pytest.mark.parametrize( + 'dt,expected_to_python,expected_to_json,expected_to_python_dict,expected_to_json_dict,mode', + [ + ( + datetime(2024, 1, 1, 0, 0, 0), + '2024-01-01T00:00:00', + b'"2024-01-01T00:00:00"', + {'2024-01-01T00:00:00': 'foo'}, + b'{"2024-01-01T00:00:00":"foo"}', + 'iso8601', + ), + ( + datetime(2024, 1, 1, 0, 0, 0), + 1704067200.0, + b'1704067200.0', + {'1704067200': 'foo'}, + b'{"1704067200":"foo"}', + 'seconds', + ), + ( + datetime(2024, 1, 1, 0, 0, 0), + 1704067200000.0, + b'1704067200000.0', + {'1704067200000': 'foo'}, + b'{"1704067200000":"foo"}', + 'milliseconds', + ), + ( + datetime(2024, 1, 1, 1, 1, 1, 23), + 1704070861.000023, + b'1704070861.000023', + {'1704070861.000023': 'foo'}, + b'{"1704070861.000023":"foo"}', + 'seconds', + ), + ( + datetime(2024, 1, 1, 1, 1, 1, 23), + 1704070861000.023, + b'1704070861000.023', + {'1704070861000.023': 'foo'}, + b'{"1704070861000.023":"foo"}', + 'milliseconds', + ), + ], +) +def test_config_datetime( + dt: datetime, expected_to_python, expected_to_json, expected_to_python_dict, expected_to_json_dict, mode +): + s = SchemaSerializer(core_schema.datetime_schema(), config={'ser_json_temporal': mode}) + assert s.to_python(dt) == dt + assert s.to_python(dt, mode='json') == expected_to_python + assert s.to_json(dt) == expected_to_json + + assert s.to_python({dt: 'foo'}) == {dt: 'foo'} + with pytest.warns( + UserWarning, + match=( + r'Expected `datetime` - serialized value may not be as expected ' + r"\[input_value=\{datetime\.datetime\([^)]*\): 'foo'\}, input_type=dict\]" + ), + ): + assert s.to_python({dt: 'foo'}, mode='json') == expected_to_python_dict + with pytest.warns( + UserWarning, + match=( + r'Expected `datetime` - serialized value may not be as expected ' + r"\[input_value=\{datetime\.datetime\([^)]*\): 'foo'\}, input_type=dict\]" + ), + ): + assert s.to_json({dt: 'foo'}) == expected_to_json_dict + + +@pytest.mark.parametrize( + 'dt,expected_to_python,expected_to_json,expected_to_python_dict,expected_to_json_dict,mode', + [ + ( + date(2024, 1, 1), + '2024-01-01', + b'"2024-01-01"', + {'2024-01-01': 'foo'}, + b'{"2024-01-01":"foo"}', + 'iso8601', + ), + ( + date(2024, 1, 1), + 1704067200.0, + b'1704067200.0', + {'1704067200': 'foo'}, + b'{"1704067200":"foo"}', + 'seconds', + ), + ( + date(2024, 1, 1), + 1704067200000.0, + b'1704067200000.0', + {'1704067200000': 'foo'}, + b'{"1704067200000":"foo"}', + 'milliseconds', + ), + ], +) +def test_config_date( + dt: date, expected_to_python, expected_to_json, expected_to_python_dict, expected_to_json_dict, mode +): + s = SchemaSerializer(core_schema.date_schema(), config={'ser_json_temporal': mode}) + assert s.to_python(dt) == dt + assert s.to_python(dt, mode='json') == expected_to_python + assert s.to_json(dt) == expected_to_json + + assert s.to_python({dt: 'foo'}) == {dt: 'foo'} + with pytest.warns( + UserWarning, + match=( + r'Expected `date` - serialized value may not be as expected ' + r"\[input_value=\{datetime\.date\([^)]*\): 'foo'\}, input_type=dict\]" + ), + ): + assert s.to_python({dt: 'foo'}, mode='json') == expected_to_python_dict + with pytest.warns( + UserWarning, + match=( + r'Expected `date` - serialized value may not be as expected ' + r"\[input_value=\{datetime\.date\([^)]*\): 'foo'\}, input_type=dict\]" + ), + ): + assert s.to_json({dt: 'foo'}) == expected_to_json_dict + + +@pytest.mark.parametrize( + 't,expected_to_python,expected_to_json,expected_to_python_dict,expected_to_json_dict,mode', + [ + ( + time(3, 14, 1, 59263), + '03:14:01.059263', + b'"03:14:01.059263"', + {'03:14:01.059263': 'foo'}, + b'{"03:14:01.059263":"foo"}', + 'iso8601', + ), + ( + time(3, 14, 1, 59263), + 11641.059263, + b'11641.059263', + {'11641.059263': 'foo'}, + b'{"11641.059263":"foo"}', + 'seconds', + ), + ( + time(3, 14, 1, 59263), + 11641059.263, + b'11641059.263', + {'11641059.263': 'foo'}, + b'{"11641059.263":"foo"}', + 'milliseconds', + ), + ], +) +def test_config_time( + t: date, expected_to_python, expected_to_json, expected_to_python_dict, expected_to_json_dict, mode +): + s = SchemaSerializer(core_schema.time_schema(), config={'ser_json_temporal': mode}) + assert s.to_python(t) == t + assert s.to_python(t, mode='json') == expected_to_python + assert s.to_json(t) == expected_to_json + + assert s.to_python({t: 'foo'}) == {t: 'foo'} + with pytest.warns( + UserWarning, + match=( + r'Expected `time` - serialized value may not be as expected ' + r"\[input_value=\{datetime\.time\([^)]*\): 'foo'\}, input_type=dict\]" + ), + ): + assert s.to_python({t: 'foo'}, mode='json') == expected_to_python_dict + with pytest.warns( + UserWarning, + match=( + r'Expected `time` - serialized value may not be as expected ' + r"\[input_value=\{datetime\.time\([^)]*\): 'foo'\}, input_type=dict\]" + ), + ): + assert s.to_json({t: 'foo'}) == expected_to_json_dict diff --git a/tests/serializers/test_timedelta.py b/tests/serializers/test_timedelta.py index b5603ee79..820c478dc 100644 --- a/tests/serializers/test_timedelta.py +++ b/tests/serializers/test_timedelta.py @@ -52,3 +52,299 @@ def test_pandas(): assert v.to_python(d) == d assert v.to_python(d, mode='json') == 'PT2H' assert v.to_json(d) == b'"PT2H"' + + +@pytest.mark.parametrize( + 'td,expected_to_python,expected_to_json,expected_to_python_dict,expected_to_json_dict,mode', + [ + (timedelta(hours=2), 7200000.0, b'7200000.0', {'7200000': 'foo'}, b'{"7200000":"foo"}', 'milliseconds'), + ( + timedelta(hours=-2), + -7200000.0, + b'-7200000.0', + {'-7200000': 'foo'}, + b'{"-7200000":"foo"}', + 'milliseconds', + ), + (timedelta(seconds=1.5), 1500.0, b'1500.0', {'1500': 'foo'}, b'{"1500":"foo"}', 'milliseconds'), + (timedelta(seconds=-1.5), -1500.0, b'-1500.0', {'-1500': 'foo'}, b'{"-1500":"foo"}', 'milliseconds'), + (timedelta(microseconds=1), 0.001, b'0.001', {'0.001': 'foo'}, b'{"0.001":"foo"}', 'milliseconds'), + ( + timedelta(microseconds=-1), + -0.001, + b'-0.001', + {'-0.001': 'foo'}, + b'{"-0.001":"foo"}', + 'milliseconds', + ), + ( + timedelta(days=1), + 86400000.0, + b'86400000.0', + {'86400000': 'foo'}, + b'{"86400000":"foo"}', + 'milliseconds', + ), + ( + timedelta(days=-1), + -86400000.0, + b'-86400000.0', + {'-86400000': 'foo'}, + b'{"-86400000":"foo"}', + 'milliseconds', + ), + ( + timedelta(days=1, seconds=1), + 86401000.0, + b'86401000.0', + {'86401000': 'foo'}, + b'{"86401000":"foo"}', + 'milliseconds', + ), + ( + timedelta(days=-1, seconds=-1), + -86401000.0, + b'-86401000.0', + {'-86401000': 'foo'}, + b'{"-86401000":"foo"}', + 'milliseconds', + ), + ( + timedelta(days=1, seconds=-1), + 86399000.0, + b'86399000.0', + {'86399000': 'foo'}, + b'{"86399000":"foo"}', + 'milliseconds', + ), + ( + timedelta(days=1, seconds=1, microseconds=1), + 86401000.001, + b'86401000.001', + {'86401000.001': 'foo'}, + b'{"86401000.001":"foo"}', + 'milliseconds', + ), + ( + timedelta(days=-1, seconds=-1, microseconds=-1), + -86401000.001, + b'-86401000.001', + {'-86401000.001': 'foo'}, + b'{"-86401000.001":"foo"}', + 'milliseconds', + ), + (timedelta(hours=2), 7200.0, b'7200.0', {'7200': 'foo'}, b'{"7200":"foo"}', 'seconds'), + (timedelta(hours=-2), -7200.0, b'-7200.0', {'-7200': 'foo'}, b'{"-7200":"foo"}', 'seconds'), + (timedelta(seconds=1.5), 1.5, b'1.5', {'1.5': 'foo'}, b'{"1.5":"foo"}', 'seconds'), + (timedelta(seconds=-1.5), -1.5, b'-1.5', {'-1.5': 'foo'}, b'{"-1.5":"foo"}', 'seconds'), + (timedelta(microseconds=1), 1e-6, b'1e-6', {'0.000001': 'foo'}, b'{"0.000001":"foo"}', 'seconds'), + ( + timedelta(microseconds=-1), + -1e-6, + b'-1e-6', + {'-0.000001': 'foo'}, + b'{"-0.000001":"foo"}', + 'seconds', + ), + (timedelta(days=1), 86400.0, b'86400.0', {'86400': 'foo'}, b'{"86400":"foo"}', 'seconds'), + (timedelta(days=-1), -86400.0, b'-86400.0', {'-86400': 'foo'}, b'{"-86400":"foo"}', 'seconds'), + (timedelta(days=1, seconds=1), 86401.0, b'86401.0', {'86401': 'foo'}, b'{"86401":"foo"}', 'seconds'), + ( + timedelta(days=-1, seconds=-1), + -86401.0, + b'-86401.0', + {'-86401': 'foo'}, + b'{"-86401":"foo"}', + 'seconds', + ), + (timedelta(days=1, seconds=-1), 86399.0, b'86399.0', {'86399': 'foo'}, b'{"86399":"foo"}', 'seconds'), + ( + timedelta(days=1, seconds=1, microseconds=1), + 86401.000001, + b'86401.000001', + {'86401.000001': 'foo'}, + b'{"86401.000001":"foo"}', + 'seconds', + ), + ( + timedelta(days=-1, seconds=-1, microseconds=-1), + -86401.000001, + b'-86401.000001', + {'-86401.000001': 'foo'}, + b'{"-86401.000001":"foo"}', + 'seconds', + ), + ], +) +def test_config_timedelta( + td: timedelta, expected_to_python, expected_to_json, expected_to_python_dict, expected_to_json_dict, mode +): + s = SchemaSerializer(core_schema.timedelta_schema(), config={'ser_json_temporal': mode}) + assert s.to_python(td) == td + assert s.to_python(td, mode='json') == expected_to_python + assert s.to_json(td) == expected_to_json + assert s.to_python({td: 'foo'}) == {td: 'foo'} + with pytest.warns(UserWarning): + assert s.to_python({td: 'foo'}, mode='json') == expected_to_python_dict + with pytest.warns( + UserWarning, + ): + assert s.to_json({td: 'foo'}) == expected_to_json_dict + + +@pytest.mark.parametrize( + 'td,expected_to_python,expected_to_json,expected_to_python_dict,expected_to_json_dict,temporal_mode', + [ + (timedelta(hours=2), 7200000.0, b'7200000.0', {'7200000': 'foo'}, b'{"7200000":"foo"}', 'milliseconds'), + ( + timedelta(hours=-2), + -7200000.0, + b'-7200000.0', + {'-7200000': 'foo'}, + b'{"-7200000":"foo"}', + 'milliseconds', + ), + (timedelta(seconds=1.5), 1500.0, b'1500.0', {'1500': 'foo'}, b'{"1500":"foo"}', 'milliseconds'), + (timedelta(seconds=-1.5), -1500.0, b'-1500.0', {'-1500': 'foo'}, b'{"-1500":"foo"}', 'milliseconds'), + (timedelta(microseconds=1), 0.001, b'0.001', {'0.001': 'foo'}, b'{"0.001":"foo"}', 'milliseconds'), + ( + timedelta(microseconds=-1), + -0.001, + b'-0.001', + {'-0.001': 'foo'}, + b'{"-0.001":"foo"}', + 'milliseconds', + ), + ( + timedelta(days=1), + 86400000.0, + b'86400000.0', + {'86400000': 'foo'}, + b'{"86400000":"foo"}', + 'milliseconds', + ), + ( + timedelta(days=-1), + -86400000.0, + b'-86400000.0', + {'-86400000': 'foo'}, + b'{"-86400000":"foo"}', + 'milliseconds', + ), + ( + timedelta(days=1, seconds=1), + 86401000.0, + b'86401000.0', + {'86401000': 'foo'}, + b'{"86401000":"foo"}', + 'milliseconds', + ), + ( + timedelta(days=-1, seconds=-1), + -86401000.0, + b'-86401000.0', + {'-86401000': 'foo'}, + b'{"-86401000":"foo"}', + 'milliseconds', + ), + ( + timedelta(days=1, seconds=-1), + 86399000.0, + b'86399000.0', + {'86399000': 'foo'}, + b'{"86399000":"foo"}', + 'milliseconds', + ), + ( + timedelta(days=1, seconds=1, microseconds=1), + 86401000.001, + b'86401000.001', + {'86401000.001': 'foo'}, + b'{"86401000.001":"foo"}', + 'milliseconds', + ), + ( + timedelta(days=-1, seconds=-1, microseconds=-1), + -86401000.001, + b'-86401000.001', + {'-86401000.001': 'foo'}, + b'{"-86401000.001":"foo"}', + 'milliseconds', + ), + (timedelta(hours=2), 7200.0, b'7200.0', {'7200': 'foo'}, b'{"7200":"foo"}', 'seconds'), + (timedelta(hours=-2), -7200.0, b'-7200.0', {'-7200': 'foo'}, b'{"-7200":"foo"}', 'seconds'), + (timedelta(seconds=1.5), 1.5, b'1.5', {'1.5': 'foo'}, b'{"1.5":"foo"}', 'seconds'), + (timedelta(seconds=-1.5), -1.5, b'-1.5', {'-1.5': 'foo'}, b'{"-1.5":"foo"}', 'seconds'), + (timedelta(microseconds=1), 1e-6, b'1e-6', {'0.000001': 'foo'}, b'{"0.000001":"foo"}', 'seconds'), + ( + timedelta(microseconds=-1), + -1e-6, + b'-1e-6', + {'-0.000001': 'foo'}, + b'{"-0.000001":"foo"}', + 'seconds', + ), + (timedelta(days=1), 86400.0, b'86400.0', {'86400': 'foo'}, b'{"86400":"foo"}', 'seconds'), + (timedelta(days=-1), -86400.0, b'-86400.0', {'-86400': 'foo'}, b'{"-86400":"foo"}', 'seconds'), + (timedelta(days=1, seconds=1), 86401.0, b'86401.0', {'86401': 'foo'}, b'{"86401":"foo"}', 'seconds'), + ( + timedelta(days=-1, seconds=-1), + -86401.0, + b'-86401.0', + {'-86401': 'foo'}, + b'{"-86401":"foo"}', + 'seconds', + ), + (timedelta(days=1, seconds=-1), 86399.0, b'86399.0', {'86399': 'foo'}, b'{"86399":"foo"}', 'seconds'), + ( + timedelta(days=1, seconds=1, microseconds=1), + 86401.000001, + b'86401.000001', + {'86401.000001': 'foo'}, + b'{"86401.000001":"foo"}', + 'seconds', + ), + ( + timedelta(days=-1, seconds=-1, microseconds=-1), + -86401.000001, + b'-86401.000001', + {'-86401.000001': 'foo'}, + b'{"-86401.000001":"foo"}', + 'seconds', + ), + ], +) +@pytest.mark.parametrize('timedelta_mode', ['iso8601', 'float']) +def test_config_timedelta_timedelta_ser_flag_prioritised( + td: timedelta, + expected_to_python, + expected_to_json, + expected_to_python_dict, + expected_to_json_dict, + temporal_mode, + timedelta_mode, +): + s = SchemaSerializer( + core_schema.timedelta_schema(), + config={'ser_json_temporal': temporal_mode, 'ser_json_timedelta': timedelta_mode}, + ) + assert s.to_python(td) == td + assert s.to_python(td, mode='json') == expected_to_python + assert s.to_python({td: 'foo'}) == {td: 'foo'} + + with pytest.warns( + UserWarning, + match=( + r'Expected `timedelta` - serialized value may not be as expected ' + r"\[input_value=\{datetime\.timedelta\([^)]*\): 'foo'\}, input_type=dict\]" + ), + ): + assert s.to_python({td: 'foo'}, mode='json') == expected_to_python_dict + with pytest.warns( + UserWarning, + match=( + r'Expected `timedelta` - serialized value may not be as expected ' + r"\[input_value=\{datetime\.timedelta\([^)]*\): 'foo'\}, input_type=dict\]" + ), + ): + assert s.to_json({td: 'foo'}) == expected_to_json_dict